diff --git a/Darp.Ble.slnx b/Darp.Ble.slnx index e801ad38..1bc916e1 100644 --- a/Darp.Ble.slnx +++ b/Darp.Ble.slnx @@ -5,11 +5,14 @@ + + + diff --git a/docs/telemetry.md b/docs/telemetry.md index 459a9755..07fda4b8 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -11,7 +11,7 @@ metrics.AddMeter(BleTelemetry.MeterName); // "Darp.Ble" `BleManagerBuilder.ConfigureDiagnostics(options => options.IncludeAddresses = false)` replaces peer addresses with `` in logs and spans. Addresses are never metric dimensions. -The backends add their own sources and event ids when they are ported to v2 (HciHost below; WinRT and Android follow); their spans nest inside the core's. +The backends add their own sources and event ids when they are ported to v2 (HciHost and WinRT below; Android follows); their spans nest inside the core's. ## Spans (source `Darp.Ble`) @@ -58,6 +58,7 @@ Span names are message templates; values are only in tags. A cancelled operation | 1200 | AdvertisingStarted | Debug | Advertising set {Address} started | | 1201 | AdvertisingStopped | Debug | Advertising set {Address} stopped | | 1202 | AdvertisingRestartFailed | Warning | Advertising set {Address} could not start again after its central disconnected | +| 1203 | AdvertisingFailed | Warning | Advertising set {Address} stopped because of an error | | 1300 | Connecting | Debug | Connecting to {Address} | | 1301 | Connected | Debug | Connected to {Address} | | 1302 | ConnectFailed | Warning | Connecting to {Address} failed: {Kind} | @@ -91,3 +92,13 @@ The HCI layer logs under categories that start with `Darp.Ble.Hci` and traces on | 2007 | AttPacketTransmission | Trace | {Source} to {Destination}: [0x{ConnectionHandle:X3}]: {PacketName} | | 2008 | ReceivedUnknownAttPacket | Warning | {Source} to {Destination}: [0x{ConnectionHandle:X3}]: {OpCode} | | 2009 | AttPacketTransmissionWarning | Warning | {Source} to {Destination}: [0x{ConnectionHandle:X3}]: {PacketName} {Message} | + +## WinRT backend (`Darp.Ble.WinRT`) + +The WinRT backend logs under categories that start with `Darp.Ble.WinRT` and has no spans of its own. + +| Id | Name | Level | Message | +| --- | --- | --- | --- | +| 3000 | NotOnWindows | Warning | The WinRT backend has no adapter: the app does not run on Windows 10 2004 or later, or was built without its Windows target | +| 3001 | ReservedSectionDropped | Warning | Windows sets advertising data section {AdType} itself; the app's section is left out | +| 3002 | HostedRequestFailed | Warning | A central's request to a hosted service could not be answered | diff --git a/src/Darp.Ble.HciHost/Connections/ConnectAttempt.cs b/src/Darp.Ble.HciHost/Connections/ConnectAttempt.cs index ec7522c5..6a851323 100644 --- a/src/Darp.Ble.HciHost/Connections/ConnectAttempt.cs +++ b/src/Darp.Ble.HciHost/Connections/ConnectAttempt.cs @@ -50,8 +50,9 @@ public void Dispose() [MessageSink] private void OnConnectionComplete(HciLeEnhancedConnectionCompleteV1Event hciEvent) { - if (hciEvent.Role is not HciLeConnectionRole.Central || _result.Task.IsCompleted) + if (_result.Task.IsCompleted) return; + // Of a failed event only the status is valid; it is this attempt's, as the controller runs one at a time if (hciEvent.Status is HciCommandStatus.UnknownConnectionIdentifier) { // The attempt was cancelled @@ -69,8 +70,11 @@ private void OnConnectionComplete(HciLeEnhancedConnectionCompleteV1Event hciEven ); return; } - // A link to another peer is not this attempt's - if (hciEvent.PeerAddress.ToUInt64() != _peer.Value.ToUInt64()) + // A link a central made to our peripheral role, or one to another peer, is not this attempt's + if ( + hciEvent.Role is not HciLeConnectionRole.Central + || hciEvent.PeerAddress.ToUInt64() != _peer.Value.ToUInt64() + ) return; if (!_device.TryGetConnection(hciEvent.ConnectionHandle, out AclConnection? connection)) return; diff --git a/src/Darp.Ble.HciHost/Connections/HciHostCentralDriver.cs b/src/Darp.Ble.HciHost/Connections/HciHostCentralDriver.cs index 1e31b535..e7941092 100644 --- a/src/Darp.Ble.HciHost/Connections/HciHostCentralDriver.cs +++ b/src/Darp.Ble.HciHost/Connections/HciHostCentralDriver.cs @@ -6,6 +6,7 @@ using Darp.Ble.Hci.Payload; using Darp.Ble.Hci.Payload.Command; using Darp.Ble.Hci.Payload.Result; +using Darp.Ble.HciHost.Gap; namespace Darp.Ble.HciHost.Connections; @@ -15,11 +16,12 @@ namespace Darp.Ble.HciHost.Connections; "CA1001:Types that own disposable fields should be disposable", Justification = "The semaphore never creates its wait handle" )] -internal sealed class HciHostCentralDriver(HciDevice device) : ICentralDriver +internal sealed class HciHostCentralDriver(HciDevice device, HciHostObserverDriver observer) : ICentralDriver { private static readonly TimeSpan MinSupervisionTimeout = TimeSpan.FromMilliseconds(720); private readonly HciDevice _device = device; + private readonly HciHostObserverDriver _observer = observer; // The controller initiates one connection at a time; further attempts wait for their turn. // Without its wait handle the semaphore holds nothing to dispose @@ -34,6 +36,19 @@ public async Task ConnectAsync( CancellationToken cancellationToken ) { + // The controller needs the address type; an address without it, e.g. parsed from text, takes the type it was heard with + if (address.Type is BleAddressType.NotAvailable) + { + address = _observer.TypeOf(address.Value) is { } type + ? address with + { + Type = type, + } + : throw new BleConnectException( + BleConnectErrorKind.Unreachable, + $"The address type of {address} is unknown; scan until the device is heard, then connect" + ); + } ushort interval = HciTiming.ToConnectionIntervalUnits( parameters.ConnectionInterval, nameof(parameters.ConnectionInterval) diff --git a/src/Darp.Ble.HciHost/Gap/AdvertisingReportAssembler.cs b/src/Darp.Ble.HciHost/Gap/AdvertisingReportAssembler.cs new file mode 100644 index 00000000..a1edc3e9 --- /dev/null +++ b/src/Darp.Ble.HciHost/Gap/AdvertisingReportAssembler.cs @@ -0,0 +1,65 @@ +using Darp.Ble.Hci.Payload.Event; + +namespace Darp.Ble.HciHost.Gap; + +/// +/// Joins the reports of one advertisement. A controller splits data that does not fit one report: every report but +/// the last says more data is to come. Reports of other advertisers and scan responses may come in between +/// +/// Only the transport's read loop uses it +internal sealed class AdvertisingReportAssembler +{ + // The most data an advertisement can carry + private const int MaxDataLength = 1650; + private const int ScanResponse = 0b01000; + private const int MoreDataToCome = 0b01; + private const sbyte TxPowerNotAvailable = 0x7F; + + private readonly Dictionary _pending = []; + + /// The data of the whole advertisement, once a report ends it + /// The next report + /// The data of the advertisement this report ends + /// Its TX power: later reports may leave out the one an earlier report gave + /// False while more data is to come, or when an advertisement is longer than any can be + public bool TryEnd(HciLeExtendedAdvertisingReport report, out ReadOnlyMemory data, out sbyte txPower) + { + // Bits 5 and 6 of the event type: complete, more data to come, or truncated + bool moreToCome = ((report.EventType >> 5) & 0b11) == MoreDataToCome; + var sender = new Sender( + report.AddressType, + report.Address.ToUInt64(), + report.AdvertisingSId, + (report.EventType & ScanResponse) != 0 + ); + bool continues = _pending.Remove(sender, out Pending earlier); + txPower = continues && report.TxPower == TxPowerNotAvailable ? earlier.TxPower : report.TxPower; + data = default; + if (!continues && !moreToCome) + { + data = report.Data; + return true; + } + // An advertisement already too long is dropped up to its last report + byte[]? joined = continues && earlier.Data is null ? null : [.. earlier.Data ?? [], .. report.Data.Span]; + if (joined?.Length > MaxDataLength) + joined = null; + if (moreToCome) + { + _pending[sender] = new Pending(joined, txPower); + return false; + } + if (joined is null) + return false; + data = joined; + return true; + } + + /// A new scan starts without the parts of the last one + public void Clear() => _pending.Clear(); + + private readonly record struct Sender(byte AddressType, ulong Address, byte AdvertisingSId, bool IsScanResponse); + + /// The data so far, or null for an advertisement too long to deliver + private readonly record struct Pending(byte[]? Data, sbyte TxPower); +} diff --git a/src/Darp.Ble.HciHost/Gap/HciHostObserverDriver.cs b/src/Darp.Ble.HciHost/Gap/HciHostObserverDriver.cs index 854a39d4..ad8011e1 100644 --- a/src/Darp.Ble.HciHost/Gap/HciHostObserverDriver.cs +++ b/src/Darp.Ble.HciHost/Gap/HciHostObserverDriver.cs @@ -19,6 +19,15 @@ internal sealed partial class HciHostObserverDriver : IObserverDriver, IDisposab private IScanSink? _sink; private bool _transportFailed; + // The type each address was last heard with, so an address known only by its value can be connected. + // Bounded: a long scan among devices with changing private addresses hears ever new ones + private const int MaxKnownAddresses = 1024; + private readonly Dictionary _addressTypes = []; + + // Used on the read loop only; a new scan's sink starts it afresh + private readonly AdvertisingReportAssembler _assembler = new(); + private IScanSink? _assembledFor; + public HciHostObserverDriver(HciDevice device) { _device = device; @@ -85,33 +94,64 @@ await _device .ConfigureAwait(false); } + /// The type the address was last heard with, if it was heard + /// The value of the address + /// The address type, or null + public BleAddressType? TypeOf(Data.UInt48 address) + { + lock (_lock) + return _addressTypes.TryGetValue((ulong)address, out BleAddressType type) ? type : null; + } + + private void Remember(ulong address, BleAddressType type) + { + // Anonymous advertisements have no address to connect to + if (type is BleAddressType.NotAvailable) + return; + if (_addressTypes.Count >= MaxKnownAddresses && !_addressTypes.ContainsKey(address)) + _addressTypes.Clear(); + _addressTypes[address] = type; + } + [MessageSink] private void OnAdvertisingReport(HciLeExtendedAdvertisingReportEvent reportEvent) { IScanSink? sink; lock (_lock) + { sink = _sink; + foreach (HciLeExtendedAdvertisingReport report in reportEvent.Reports) + Remember(report.Address.ToUInt64(), (BleAddressType)report.AddressType); + } if (sink is null) return; + if (!ReferenceEquals(sink, _assembledFor)) + { + _assembler.Clear(); + _assembledFor = sink; + } DateTimeOffset timestamp = _device.TimeProvider.GetUtcNow(); foreach (HciLeExtendedAdvertisingReport report in reportEvent.Reports) { + if (!_assembler.TryEnd(report, out ReadOnlyMemory data, out sbyte txPower)) + continue; sink.OnReport( - GapAdvertisement.FromExtendedAdvertisingReport( + GapAdvertisement.Create( timestamp, - (BleEventType)report.EventType, + // Without the data status in bits 5 and 6: the advertisement is whole + (BleEventType)(report.EventType & 0b11111), new BleAddress((BleAddressType)report.AddressType, (Data.UInt48)report.Address.ToUInt64()), (Physical)report.PrimaryPhy, (Physical)report.SecondaryPhy, (AdvertisingSId)report.AdvertisingSId, - (TxPowerLevel)report.TxPower, + (TxPowerLevel)txPower, (Rssi)report.Rssi, (PeriodicAdvertisingInterval)report.PeriodicAdvertisingInterval, new BleAddress( (BleAddressType)report.DirectAddressType, (Data.UInt48)report.DirectAddress.ToUInt64() ), - AdvertisingData.From(report.Data) + AdvertisingData.From(data) ) ); } diff --git a/src/Darp.Ble.HciHost/HciHostAdapterDriver.cs b/src/Darp.Ble.HciHost/HciHostAdapterDriver.cs index ffc34b3d..c8eb3cb5 100644 --- a/src/Darp.Ble.HciHost/HciHostAdapterDriver.cs +++ b/src/Darp.Ble.HciHost/HciHostAdapterDriver.cs @@ -28,7 +28,7 @@ int numberOfAdvertisingSets _device = device; _observer = new HciHostObserverDriver(device); _peripheral = new HciHostPeripheralDriver(device, includeGattService); - _central = new HciHostCentralDriver(device); + _central = new HciHostCentralDriver(device, _observer); _broadcaster = new HciHostBroadcasterDriver( device, maxAdvertisingDataLength, diff --git a/src/Darp.Ble.Mock/Gap/MockObserverDriver.cs b/src/Darp.Ble.Mock/Gap/MockObserverDriver.cs index eae27f47..da9c607b 100644 --- a/src/Darp.Ble.Mock/Gap/MockObserverDriver.cs +++ b/src/Darp.Ble.Mock/Gap/MockObserverDriver.cs @@ -60,7 +60,7 @@ Rssi rssi { AdvertisingParameters parameters = advertising.Parameters; bool isLegacy = eventType.HasFlag(BleEventType.Legacy); - return GapAdvertisement.FromExtendedAdvertisingReport( + return GapAdvertisement.Create( timestamp, eventType, advertising.Address, diff --git a/src/Darp.Ble.Mock/MockAir.cs b/src/Darp.Ble.Mock/MockAir.cs index 795e5727..cfe3787e 100644 --- a/src/Darp.Ble.Mock/MockAir.cs +++ b/src/Darp.Ble.Mock/MockAir.cs @@ -53,7 +53,11 @@ public void StopAdvertising(MockAdvertisingSetDriver set) set => !ReferenceEquals(set.Adapter, central) && set.Adapter.IsInRange - && set.RandomAddress == address + // An address without its type, e.g. parsed from text, is looked up by its value + && ( + set.RandomAddress == address + || (address.Type is BleAddressType.NotAvailable && set.RandomAddress.Value == address.Value) + ) && set.Type.HasFlag(BleEventType.Connectable) ); } diff --git a/src/Darp.Ble.WinRT/BleHelper.cs b/src/Darp.Ble.WinRT/BleHelper.cs deleted file mode 100644 index 6f5e9289..00000000 --- a/src/Darp.Ble.WinRT/BleHelper.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Darp.Ble.Data; -using Windows.Devices.Bluetooth; - -namespace Darp.Ble.WinRT; - -internal static class BleHelper -{ - public static BleAddress GetBleAddress(ulong winAddress, BluetoothAddressType winAddressType) - { - BleAddressType addressType = winAddressType switch - { - BluetoothAddressType.Public => BleAddressType.Public, - BluetoothAddressType.Random => BleAddressType.Random, - _ => BleAddressType.NotAvailable, - }; - return new BleAddress(addressType, (UInt48)winAddress); - } -} diff --git a/src/Darp.Ble.WinRT/Connections/AttributeWrappers.cs b/src/Darp.Ble.WinRT/Connections/AttributeWrappers.cs new file mode 100644 index 00000000..1fba55b7 --- /dev/null +++ b/src/Darp.Ble.WinRT/Connections/AttributeWrappers.cs @@ -0,0 +1,68 @@ +namespace Darp.Ble.WinRT.Connections; + +/// +/// A connection's wrappers of one kind of native GATT object, one per attribute handle, so every discovery returns +/// the same ones. A rediscovered attribute keeps its wrapper and native object: Windows may return the object the +/// wrapper holds, so releasing the new one could close the wrapped one. Once the connection ended it wraps nothing +/// more: its end hands every wrapper to disposal, and a discovery that completes later releases its native objects instead. +/// Generic over the native and wrapper types so the bookkeeping builds and is tested without Windows +/// +internal sealed class AttributeWrappers + where TWrapper : class +{ + private readonly Func _handleOf; + private readonly Func _wrap; + private readonly Action _release; + private readonly Lock _lock = new(); + private readonly Dictionary _byHandle = []; + private bool _ended; + + /// Starts without wrappers + /// The attribute handle of a native object + /// Wraps a native object the first time its handle shows up + /// Releases a native object of a discovery that completed after the end + public AttributeWrappers(Func handleOf, Func wrap, Action release) + { + _handleOf = handleOf; + _wrap = wrap; + _release = release; + } + + /// The wrappers of what a discovery returned; null once the connection ended + public IReadOnlyList? Wrap(IReadOnlyList natives) + { + // Under the lock, so a wrapper is either part of the end or never created + lock (_lock) + { + if (_ended) + { + foreach (TNative native in natives) + _release(native); + return null; + } + var wrappers = new TWrapper[natives.Count]; + for (var i = 0; i < natives.Count; i++) + { + TNative native = natives[i]; + if (_byHandle.TryGetValue(_handleOf(native), out TWrapper? known)) + { + wrappers[i] = known; + continue; + } + wrappers[i] = _wrap(native); + _byHandle.Add(_handleOf(native), wrappers[i]); + } + return wrappers; + } + } + + /// The connection ended; returns every wrapper handed out, for the caller to release + public IReadOnlyList End() + { + lock (_lock) + { + _ended = true; + return [.. _byHandle.Values]; + } + } +} diff --git a/src/Darp.Ble.WinRT/Connections/CentralSessions.cs b/src/Darp.Ble.WinRT/Connections/CentralSessions.cs new file mode 100644 index 00000000..3339f609 --- /dev/null +++ b/src/Darp.Ble.WinRT/Connections/CentralSessions.cs @@ -0,0 +1,78 @@ +using System.Runtime.CompilerServices; + +namespace Darp.Ble.WinRT.Connections; + +/// +/// One central per device for as long as its connection lasts, across the GATT sessions Windows hands out. A connection +/// ends when its last session closed or this app ended it; the device's next activity is a new connection, and late +/// callbacks of the old sessions are ignored. Not thread-safe: its owner calls it under its own lock. +/// Generic over the session and central types so the bookkeeping builds and is tested without Windows +/// +internal sealed class CentralSessions + where TSession : class + where TCentral : class +{ + private readonly Dictionary _byDevice = new(StringComparer.Ordinal); + private readonly Dictionary _bySession = new(ReferenceEqualityComparer.Instance); + + // Closed sessions, so their late callbacks do not bring an ended connection back; weak, so they can go away + private readonly ConditionalWeakTable _closed = []; + + /// + /// The central of the session's device; null for a session that closed. Says whether the central and the session + /// are new, so each session can be watched for closing and each central announced once + /// + public (TCentral? Central, bool IsNewCentral, bool IsNewSession) Resolve( + string deviceId, + TSession session, + Func create + ) + { + if (_closed.TryGetValue(session, out _)) + return (null, false, false); + bool isNewCentral = !_byDevice.TryGetValue(deviceId, out Connection? connection); + if (connection is null) + { + connection = new Connection(deviceId, create()); + _byDevice[deviceId] = connection; + } + bool isNewSession = connection.Sessions.Add(session); + _bySession[session] = connection; + return (connection.Central, isNewCentral, isNewSession); + } + + /// A session closed; returns the central when that ended its connection + public TCentral? Close(TSession session) + { + _closed.AddOrUpdate(session, session); + if (!_bySession.Remove(session, out Connection? connection)) + return null; + connection.Sessions.Remove(session); + if (connection.Sessions.Count > 0) + return null; + _byDevice.Remove(connection.DeviceId); + return connection.Central; + } + + /// This app ended the central's connection; returns false when it had ended already + public bool End(TCentral central) + { + Connection? connection = _byDevice.Values.FirstOrDefault(c => ReferenceEquals(c.Central, central)); + if (connection is null) + return false; + _byDevice.Remove(connection.DeviceId); + foreach (TSession session in connection.Sessions) + { + _bySession.Remove(session); + _closed.AddOrUpdate(session, session); + } + return true; + } + + private sealed class Connection(string deviceId, TCentral central) + { + public string DeviceId { get; } = deviceId; + public TCentral Central { get; } = central; + public HashSet Sessions { get; } = new(ReferenceEqualityComparer.Instance); + } +} diff --git a/src/Darp.Ble.WinRT/Darp.Ble.WinRT.csproj b/src/Darp.Ble.WinRT/Darp.Ble.WinRT.csproj index 04b8218c..8dc58ad7 100644 --- a/src/Darp.Ble.WinRT/Darp.Ble.WinRT.csproj +++ b/src/Darp.Ble.WinRT/Darp.Ble.WinRT.csproj @@ -1,24 +1,18 @@ - + net10.0;net10.0-windows10.0.19041.0 - true + 13 true - + - - - - - + - - <_Parameter1>TargetFramework - <_Parameter2>$(TargetFramework) - + + diff --git a/src/Darp.Ble.WinRT/Gap/PublisherSections.cs b/src/Darp.Ble.WinRT/Gap/PublisherSections.cs new file mode 100644 index 00000000..619f547a --- /dev/null +++ b/src/Darp.Ble.WinRT/Gap/PublisherSections.cs @@ -0,0 +1,62 @@ +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.Gap; + +namespace Darp.Ble.WinRT.Gap; + +/// Windows' advertisement publisher sets some data sections itself and refuses them from the app +/// +internal static class PublisherSections +{ + private static readonly HashSet Reserved = + [ + AdTypes.Flags, + AdTypes.IncompleteListOf16BitServiceOrServiceClassUuids, + AdTypes.CompleteListOf16BitServiceOrServiceClassUuids, + AdTypes.IncompleteListOf32BitServiceOrServiceClassUuids, + AdTypes.CompleteListOf32BitServiceOrServiceClassUuids, + AdTypes.IncompleteListOf128BitServiceOrServiceClassUuids, + AdTypes.CompleteListOf128BitServiceOrServiceClassUuids, + AdTypes.ShortenedLocalName, + AdTypes.CompleteLocalName, + AdTypes.TxPowerLevel, + AdTypes.ClassOfDevice, + AdTypes.SimplePairingHashC192, + AdTypes.SimplePairingRandomizerR192, + AdTypes.SecurityManagerTkValue, + AdTypes.SecurityManagerOutOfBandFlags, + AdTypes.PeripheralConnectionIntervalRange, + AdTypes.ListOf16BitServiceSolicitationUuids, + AdTypes.ListOf32BitServiceSolicitationUuids, + AdTypes.ListOf128BitServiceSolicitationUuids, + AdTypes.ServiceData16BitUuid, + AdTypes.ServiceData32BitUuid, + AdTypes.ServiceData128BitUuid, + AdTypes.PublicTargetAddress, + AdTypes.RandomTargetAddress, + AdTypes.Appearance, + AdTypes.AdvertisingInterval, + AdTypes.LeBluetoothDeviceAddress, + AdTypes.LeRole, + AdTypes.SimplePairingHashC256, + AdTypes.SimplePairingRandomizerR256, + AdTypes.ThreeDInformationData, + ]; + + /// The sections the publisher takes, and the types it would refuse + public static ( + IReadOnlyList<(AdTypes Type, ReadOnlyMemory Bytes)> Kept, + IReadOnlyList Dropped + ) Split(AdvertisingData data) + { + List<(AdTypes, ReadOnlyMemory)> kept = []; + List dropped = []; + foreach ((AdTypes type, ReadOnlyMemory bytes) in data) + { + if (Reserved.Contains(type)) + dropped.Add(type); + else + kept.Add((type, bytes)); + } + return (kept, dropped); + } +} diff --git a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerCharacteristic.cs b/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerCharacteristic.cs deleted file mode 100644 index ba306879..00000000 --- a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerCharacteristic.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Reactive.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Darp.Ble.Data; -using Darp.Ble.Exceptions; -using Darp.Ble.Gatt.Server; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.GenericAttributeProfile; -using Windows.Foundation; - -namespace Darp.Ble.WinRT.Gatt.Server; - -internal sealed class WinGattServerCharacteristic( - GattServerService service, - GattCharacteristic gattCharacteristic, - ILogger logger -) - : GattServerCharacteristic( - service, - gattCharacteristic.AttributeHandle, - BleUuid.FromGuid(gattCharacteristic.Uuid, inferType: true), - (GattProperty)gattCharacteristic.CharacteristicProperties, - logger - ) -{ - private readonly GattCharacteristic _gattCharacteristic = gattCharacteristic; - - /// - protected override IObservable DiscoverDescriptorsCore() - { - return Observable.Create(observer => - { - return Observable - .FromAsync(token => _gattCharacteristic.GetDescriptorsAsync().AsTask(token)) - .Subscribe( - result => - { - if (result.Status is not GattCommunicationStatus.Success) - { - observer.OnError( - new Exception( - $"Could not query new descriptor for characteristic - got result {result.Status} ({result.ProtocolError})" - ) - ); - return; - } - foreach (GattDescriptor descriptor in result.Descriptors) - { - observer.OnNext( - new WinGattServerDescriptor( - this, - descriptor, - ServiceProvider.GetLogger() - ) - ); - } - }, - observer.OnError, - observer.OnCompleted - ); - }); - } - - /// - protected override async Task WriteAsyncCore(byte[] bytes, CancellationToken cancellationToken) - { - GattWriteResult result = await _gattCharacteristic - .WriteValueWithResultAsync(bytes.AsBuffer(), GattWriteOption.WriteWithResponse) - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (result.Status is GattCommunicationStatus.Success) - return; - if (result.Status is GattCommunicationStatus.ProtocolError) - throw new GattCharacteristicException( - this, - $"Could not write because of protocol error {result.ProtocolError}" - ); - throw new GattCharacteristicException(this, $"Could not write because of {result.Status}"); - } - - protected override void WriteWithoutResponseCore(byte[] bytes) - { - _ = Task.Run(() => _gattCharacteristic.WriteValueAsync(bytes.AsBuffer(), GattWriteOption.WriteWithoutResponse)); - } - - protected override async Task ReadAsyncCore(CancellationToken cancellationToken) - { - GattReadResult result = await _gattCharacteristic - .ReadValueAsync(BluetoothCacheMode.Uncached) - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (result.Status is not GattCommunicationStatus.Success) - throw new Exception($"Could not read value because of {result.Status} ({result.ProtocolError})"); - return result.Value.ToArray(); - } - - protected override async Task EnableNotificationsAsync( - TState state, - Action onNotify, - CancellationToken cancellationToken - ) - { - if (!_gattCharacteristic.CharacteristicProperties.HasFlag(GattCharacteristicProperties.Notify)) - { - throw new GattCharacteristicException(this, "Characteristic does not support notification"); - } - GattCommunicationStatus res = await _gattCharacteristic - .WriteClientCharacteristicConfigurationDescriptorAsync( - GattClientCharacteristicConfigurationDescriptorValue.Notify - ) - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (res is not GattCommunicationStatus.Success) - { - throw new GattCharacteristicException(this, "Could not write notification status to cccd"); - } - - return Observable - .FromEventPattern< - TypedEventHandler, - GattCharacteristic, - GattValueChangedEventArgs - >( - addHandler => _gattCharacteristic.ValueChanged += addHandler, - removeHandler => _gattCharacteristic.ValueChanged -= removeHandler - ) - .Select(x => x.EventArgs.CharacteristicValue.ToArray()) - .Where(x => x is not null) - .Subscribe(bytes => onNotify(state, bytes)); - } - - protected override async Task DisableNotificationsAsync() - { - await _gattCharacteristic.WriteClientCharacteristicConfigurationDescriptorAsync( - GattClientCharacteristicConfigurationDescriptorValue.None - ); - } -} diff --git a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerDescriptor.cs b/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerDescriptor.cs deleted file mode 100644 index 9c64fbd6..00000000 --- a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerDescriptor.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Runtime.InteropServices.WindowsRuntime; -using Darp.Ble.Data; -using Darp.Ble.Gatt.Server; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth.GenericAttributeProfile; - -namespace Darp.Ble.WinRT.Gatt.Server; - -internal sealed class WinGattServerDescriptor( - GattServerCharacteristic characteristic, - GattDescriptor winDescriptor, - ILogger logger -) : GattServerDescriptor(characteristic, BleUuid.FromGuid(winDescriptor.Uuid, inferType: true), logger) -{ - private readonly GattDescriptor _winDescriptor = winDescriptor; - - public override async Task ReadAsync(CancellationToken cancellationToken = default) - { - GattReadResult result = await _winDescriptor.ReadValueAsync().AsTask(cancellationToken).ConfigureAwait(false); - if (result.Status is not GattCommunicationStatus.Success) - throw new Exception($"Could not read value because of {result.Status} ({result.ProtocolError})"); - return result.Value.ToArray(); - } - - public override async Task WriteAsync(byte[] bytes, CancellationToken cancellationToken = default) - { - GattWriteResult result = await _winDescriptor - .WriteValueWithResultAsync(bytes.AsBuffer()) - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (result.Status is GattCommunicationStatus.Success) - return true; - if (result.Status is GattCommunicationStatus.ProtocolError) - throw new Exception($"Could not write because of protocol error {result.ProtocolError}"); - throw new Exception($"Could not write because of {result.Status}"); - } -} diff --git a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerPeer.cs b/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerPeer.cs deleted file mode 100644 index fdb75e2f..00000000 --- a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerPeer.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Reactive.Disposables; -using System.Reactive.Linq; -using Darp.Ble.Data; -using Darp.Ble.Gatt; -using Darp.Ble.Gatt.Server; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.GenericAttributeProfile; -using Windows.Devices.Enumeration; -using Windows.Foundation; - -namespace Darp.Ble.WinRT.Gatt.Server; - -internal sealed class WinGattServerPeer : GattServerPeer -{ - private readonly BluetoothLEDevice _winDev; - - internal WinGattServerPeer(WinBleCentral central, BluetoothLEDevice winDev, ILogger logger) - : base(central, BleHelper.GetBleAddress(winDev.BluetoothAddress, winDev.BluetoothAddressType), logger) - { - _winDev = winDev; - Observable - .FromEventPattern, BluetoothLEDevice, object>( - addHandler => winDev.ConnectionStatusChanged += addHandler, - removeHandler => winDev.ConnectionStatusChanged -= removeHandler - ) - .Where(x => x.Sender is not null) - .Select(x => - x.Sender!.ConnectionStatus is BluetoothConnectionStatus.Connected - ? ConnectionStatus.Connected - : ConnectionStatus.Disconnected - ) - .Subscribe(ConnectionSubject); - } - - private IObservable DiscoverService(Func> getServices) - { - return Observable.Create( - async (observer, cancellationToken) => - { - DeviceAccessStatus accessStatus = await _winDev - .RequestAccessAsync() - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (accessStatus is not DeviceAccessStatus.Allowed) - { - observer.OnError(new Exception($"Access request disallowed: {accessStatus}...")); - return Disposable.Empty; - } - return Observable - .FromAsync(token => getServices().AsTask(token)) - .Subscribe( - result => - { - if (result.Status is not GattCommunicationStatus.Success) - { - observer.OnError( - new Exception( - $"Could not query new services for device - got result {result.Status} ({result.ProtocolError})" - ) - ); - return; - } - - foreach (GattDeviceService gattDeviceService in result.Services) - { - observer.OnNext( - new WinGattServerService( - this, - gattDeviceService, - ServiceProvider.GetLogger() - ) - ); - } - }, - observer.OnError, - observer.OnCompleted - ); - } - ); - } - - /// - protected override IObservable DiscoverServicesCore() - { - return DiscoverService(() => _winDev.GetGattServicesAsync(BluetoothCacheMode.Uncached)); - } - - /// - protected override IObservable DiscoverServiceCore(BleUuid uuid) - { - return DiscoverService(() => _winDev.GetGattServicesForUuidAsync(uuid.Value, BluetoothCacheMode.Uncached)); - } - - /// Disposes of all Services and the Device. Completes when a disconnection event was received - protected override async ValueTask DisposeAsyncCore() - { - await base.DisposeAsyncCore().ConfigureAwait(false); - foreach (WinGattServerService service in Services.OfType()) - service.Dispose(); - _winDev.Dispose(); - ConnectionSubject.OnNext(ConnectionStatus.Disconnected); - } -} diff --git a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerService.cs b/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerService.cs deleted file mode 100644 index 0c5fec75..00000000 --- a/src/Darp.Ble.WinRT/Gatt/Server/WinGattServerService.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Reactive.Disposables; -using System.Reactive.Linq; -using Darp.Ble.Data; -using Darp.Ble.Gatt; -using Darp.Ble.Gatt.Server; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth.GenericAttributeProfile; -using Windows.Devices.Enumeration; -using Windows.Foundation; - -namespace Darp.Ble.WinRT.Gatt.Server; - -internal sealed class WinGattServerService( - GattServerPeer peer, - GattDeviceService winService, - ILogger logger -) : GattServerService(peer, BleUuid.FromGuid(winService.Uuid, inferType: true), GattServiceType.Undefined, logger) -{ - private readonly GattDeviceService _winService = winService; - - private IObservable DiscoverCharacteristic( - Func> getServices - ) - { - return Observable.Create( - async (observer, cancellationToken) => - { - DeviceAccessStatus accessStatus = await _winService - .RequestAccessAsync() - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (accessStatus is not DeviceAccessStatus.Allowed) - { - observer.OnError(new Exception($"Access request disallowed: {accessStatus}...")); - return Disposable.Empty; - } - return Observable - .FromAsync(token => getServices().AsTask(token)) - .Subscribe( - result => - { - if (result.Status is not GattCommunicationStatus.Success) - { - observer.OnError( - new Exception( - $"Could not query new services for device - got result {result.Status} ({result.ProtocolError})" - ) - ); - return; - } - - foreach (GattCharacteristic gattCharacteristic in result.Characteristics) - { - observer.OnNext( - new WinGattServerCharacteristic( - this, - gattCharacteristic, - ServiceProvider.GetLogger() - ) - ); - } - }, - observer.OnError, - observer.OnCompleted - ); - } - ); - } - - /// - protected override IObservable DiscoverCharacteristicsCore() - { - return DiscoverCharacteristic(() => _winService.GetCharacteristicsAsync()); - } - - /// - protected override IObservable DiscoverCharacteristicsCore(BleUuid uuid) - { - return DiscoverCharacteristic(() => _winService.GetCharacteristicsForUuidAsync(uuid.Value)); - } - - /// Dispose the underlying Windows service. Required for successful disconnection! - internal void Dispose() => _winService.Dispose(); -} diff --git a/src/Darp.Ble.WinRT/Gatt/SubscriptionSnapshots.cs b/src/Darp.Ble.WinRT/Gatt/SubscriptionSnapshots.cs new file mode 100644 index 00000000..47df07ed --- /dev/null +++ b/src/Darp.Ble.WinRT/Gatt/SubscriptionSnapshots.cs @@ -0,0 +1,46 @@ +namespace Darp.Ble.WinRT.Gatt; + +/// +/// Windows reports the subscribed clients of a characteristic as a whole list on every change, also for raw CCCD writes. +/// This turns those lists into who subscribed and who left, per characteristic +/// +internal sealed class SubscriptionSnapshots + where TCharacteristic : notnull + where TClient : notnull +{ + private readonly Lock _lock = new(); + private readonly Dictionary> _subscribed = []; + + /// Takes the characteristic's current list; repeating it reports nothing + public (IReadOnlyList Added, IReadOnlyList Removed) Update( + TCharacteristic characteristic, + IEnumerable subscribed + ) + { + HashSet current = [.. subscribed]; + lock (_lock) + { + HashSet previous = _subscribed.GetValueOrDefault(characteristic) ?? []; + TClient[] added = [.. current.Where(client => !previous.Contains(client))]; + TClient[] removed = [.. previous.Where(client => !current.Contains(client))]; + _subscribed[characteristic] = current; + return (added, removed); + } + } + + public bool IsSubscribed(TCharacteristic characteristic, TClient client) + { + lock (_lock) + return _subscribed.GetValueOrDefault(characteristic)?.Contains(client) is true; + } + + /// The client's connection ended: it is subscribed nowhere, and a later list with it counts as new + public void Forget(TClient client) + { + lock (_lock) + { + foreach (HashSet clients in _subscribed.Values) + clients.Remove(client); + } + } +} diff --git a/src/Darp.Ble.WinRT/Gatt/WinGattClientCharacteristic.cs b/src/Darp.Ble.WinRT/Gatt/WinGattClientCharacteristic.cs deleted file mode 100644 index aa6a423b..00000000 --- a/src/Darp.Ble.WinRT/Gatt/WinGattClientCharacteristic.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Runtime.InteropServices.WindowsRuntime; -using Darp.Ble.Data; -using Darp.Ble.Gatt.Att; -using Darp.Ble.Gatt.Client; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.GenericAttributeProfile; -using Windows.Foundation; -using Windows.Storage.Streams; - -namespace Darp.Ble.WinRT.Gatt; - -internal sealed class WinGattClientCharacteristic : GattClientCharacteristic -{ - public new WinGattClientService Service { get; } - private readonly GattLocalCharacteristic _winCharacteristic; - - public WinGattClientCharacteristic( - WinGattClientService winService, - GattLocalCharacteristic winCharacteristic, - IGattCharacteristicValue value, - ILogger logger - ) - : base(winService, (GattProperty)winCharacteristic.CharacteristicProperties, value, logger) - { - Service = winService; - _winCharacteristic = winCharacteristic; - winCharacteristic.ReadRequested += async (_, args) => - { - using Deferral deferral = args.GetDeferral(); - GattReadRequest? request = await args.GetRequestAsync().AsTask().ConfigureAwait(false); - try - { - IGattClientPeer peerClient = Service.Peripheral.GetOrRegisterSession(args.Session); - byte[] readValue = await Value.ReadValueAsync(peerClient).ConfigureAwait(false); - request.RespondWithValue(readValue.AsBuffer()); - } - catch - { - request.RespondWithProtocolError(123); - } - }; - winCharacteristic.WriteRequested += async (_, args) => - { - using Deferral deferral = args.GetDeferral(); - GattWriteRequest request = await args.GetRequestAsync().AsTask().ConfigureAwait(false); - try - { - IGattClientPeer peerClient = Service.Peripheral.GetOrRegisterSession(args.Session); - DataReader reader = DataReader.FromBuffer(request.Value); - byte[] bytes = reader.DetachBuffer().ToArray(); - GattProtocolStatus status = await Value.WriteValueAsync(peerClient, bytes).ConfigureAwait(false); - if (request.Option == GattWriteOption.WriteWithResponse) - { - if (status is GattProtocolStatus.Success) - request.Respond(); - else - request.RespondWithProtocolError((byte)status); - } - } - catch - { - // ignored - } - }; - } - - protected override void OnDescriptorAdded(IGattCharacteristicValue value) - { - BleUuid uuid = value.AttributeType; - GattLocalDescriptorResult result = _winCharacteristic - .CreateDescriptorAsync(uuid.Value, new GattLocalDescriptorParameters()) - .GetResults(); - if (result.Error is not BluetoothError.Success) - throw new Exception("Could not add descriptor to windows"); - var winDescriptor = result.Descriptor; - winDescriptor.ReadRequested += async (_, args) => - { - using Deferral deferral = args.GetDeferral(); - GattReadRequest? request = await args.GetRequestAsync().AsTask().ConfigureAwait(false); - try - { - IGattClientPeer peerClient = Service.Peripheral.GetOrRegisterSession(args.Session); - byte[] readValue = await Value.ReadValueAsync(peerClient).ConfigureAwait(false); - request.RespondWithValue(readValue.AsBuffer()); - } - catch - { - request.RespondWithProtocolError(123); - } - }; - winDescriptor.WriteRequested += async (_, args) => - { - using Deferral deferral = args.GetDeferral(); - GattWriteRequest request = await args.GetRequestAsync().AsTask().ConfigureAwait(false); - try - { - IGattClientPeer peerClient = Service.Peripheral.GetOrRegisterSession(args.Session); - DataReader reader = DataReader.FromBuffer(request.Value); - byte[] bytes = reader.DetachBuffer().ToArray(); - GattProtocolStatus status = await Value.WriteValueAsync(peerClient, bytes).ConfigureAwait(false); - if (request.Option == GattWriteOption.WriteWithResponse) - { - if (status is GattProtocolStatus.Success) - request.Respond(); - else - request.RespondWithProtocolError((byte)status); - } - } - catch - { - // ignored - } - }; - } - - protected override async ValueTask NotifyAsyncCore(IGattClientPeer clientPeer, byte[] value) - { - GattSubscribedClient? subscribedClient = _winCharacteristic.SubscribedClients.FirstOrDefault(x => - { - BleAddress address = BleAddress.Parse(x.Session.DeviceId.Id[^17..], provider: null); - return address == clientPeer.Address; - }); - if (subscribedClient is null) - return; - - await _winCharacteristic - .NotifyValueAsync(value.AsBuffer(), subscribedClient) - .AsTask(CancellationToken.None) - .ConfigureAwait(false); - } - - protected override async Task IndicateAsyncCore( - IGattClientPeer clientPeer, - byte[] value, - CancellationToken cancellationToken - ) - { - GattSubscribedClient? subscribedClient = _winCharacteristic.SubscribedClients.FirstOrDefault(x => - { - BleAddress address = BleAddress.Parse(x.Session.DeviceId.Id[^17..], provider: null); - return address == clientPeer.Address; - }); - if (subscribedClient is null) - return; - - GattClientNotificationResult result = await _winCharacteristic - .NotifyValueAsync(value.AsBuffer(), subscribedClient) - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (result.Status is not GattCommunicationStatus.Success) - throw new Exception("Hey thats not good :("); - } -} diff --git a/src/Darp.Ble.WinRT/Gatt/WinGattClientPeer.cs b/src/Darp.Ble.WinRT/Gatt/WinGattClientPeer.cs deleted file mode 100644 index e24317c8..00000000 --- a/src/Darp.Ble.WinRT/Gatt/WinGattClientPeer.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using Darp.Ble.Data; -using Darp.Ble.Gatt.Client; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth.GenericAttributeProfile; -using Windows.Foundation; - -namespace Darp.Ble.WinRT.Gatt; - -internal sealed class WinGattClientPeer( - WinBlePeripheral peripheral, - GattSession session, - BleAddress address, - ILogger logger -) : GattClientPeer(peripheral, address, logger) -{ - private readonly GattSession _session = session; - - public override bool IsConnected => _session.SessionStatus is GattSessionStatus.Active; - - public override IObservable WhenDisconnected => - Observable - .FromEventPattern< - TypedEventHandler, - GattSession, - GattSessionStatusChangedEventArgs - >( - addHandler => _session.SessionStatusChanged += addHandler, - removeHandler => _session.SessionStatusChanged -= removeHandler - ) - .Where(x => x.EventArgs.Status is GattSessionStatus.Closed) - .Select(_ => Unit.Default); -} diff --git a/src/Darp.Ble.WinRT/Gatt/WinGattClientService.cs b/src/Darp.Ble.WinRT/Gatt/WinGattClientService.cs deleted file mode 100644 index 8e0b93a6..00000000 --- a/src/Darp.Ble.WinRT/Gatt/WinGattClientService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Runtime.InteropServices.WindowsRuntime; -using Darp.Ble.Data; -using Darp.Ble.Data.AssignedNumbers; -using Darp.Ble.Gap; -using Darp.Ble.Gatt; -using Darp.Ble.Gatt.Att; -using Darp.Ble.Gatt.Client; -using Darp.Ble.Gatt.Server; -using Darp.Ble.Utils; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.GenericAttributeProfile; - -namespace Darp.Ble.WinRT.Gatt; - -internal sealed class WinGattClientService( - WinBlePeripheral peripheral, - GattServiceProvider provider, - ILogger logger -) - : GattClientService( - peripheral, - BleUuid.FromGuid(provider.Service.Uuid, inferType: true), - GattServiceType.Undefined, - logger - ) -{ - private readonly GattServiceProvider _serviceProvider = provider; - private readonly GattLocalService _winService = provider.Service; - public new WinBlePeripheral Peripheral { get; } = peripheral; - - protected override GattClientCharacteristic CreateCharacteristicCore( - GattProperty properties, - IGattCharacteristicValue value - ) - { - GattLocalCharacteristicResult result = _winService - .CreateCharacteristicAsync( - value.AttributeType.Value, - new GattLocalCharacteristicParameters - { - CharacteristicProperties = (GattCharacteristicProperties)properties, - ReadProtectionLevel = GattProtectionLevel.EncryptionAndAuthenticationRequired, - } - ) - .GetResults(); - if (result.Error is not BluetoothError.Success) - throw new Exception("Nopiii"); - result.Characteristic.SubscribedClientsChanged += (sender, _) => - { - foreach (GattSubscribedClient senderSubscribedClient in sender.SubscribedClients) - { - Peripheral.GetOrRegisterSession(senderSubscribedClient.Session); - } - }; - return new WinGattClientCharacteristic( - this, - result.Characteristic, - value, - ServiceProvider.GetLogger() - ); - } - - public IAsyncDisposable Advertise(IAdvertisingSet advertisingSet) - { - AdvertisingParameters parameters = advertisingSet.Parameters; - var winParameters = new GattServiceProviderAdvertisingParameters(); - if (parameters.Type.HasFlag(BleEventType.Connectable)) - winParameters.IsConnectable = true; - winParameters.IsDiscoverable = true; - if (advertisingSet.Data.TryGetFirstType(AdTypes.ServiceData16BitUuid, out ReadOnlyMemory memory)) - { - winParameters.ServiceData = memory.ToArray().AsBuffer(); - } - _serviceProvider.StartAdvertising(winParameters); - return AsyncDisposable.Create( - _serviceProvider, - provider => - { - provider.StopAdvertising(); - return ValueTask.CompletedTask; - } - ); - } -} diff --git a/src/Darp.Ble.WinRT/InternalHelpers.cs b/src/Darp.Ble.WinRT/InternalHelpers.cs deleted file mode 100644 index e17af8dd..00000000 --- a/src/Darp.Ble.WinRT/InternalHelpers.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Darp.Ble.WinRT; - -internal static class InternalHelpers -{ - public static ILogger GetLogger(this IServiceProvider serviceProvider) - { - return serviceProvider.GetService>() ?? NullLogger.Instance; - } -} diff --git a/src/Darp.Ble.WinRT/Logging.cs b/src/Darp.Ble.WinRT/Logging.cs index 4587ade3..c1d05cb5 100644 --- a/src/Darp.Ble.WinRT/Logging.cs +++ b/src/Darp.Ble.WinRT/Logging.cs @@ -1,22 +1,30 @@ -using Darp.Ble.Data.AssignedNumbers; using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.Advertisement; namespace Darp.Ble.WinRT; internal static partial class Logging { [LoggerMessage( + EventId = 3000, + EventName = "NotOnWindows", Level = LogLevel.Warning, - Message = "Ignoring data section {AdType}. This type is reserved by Windows" + Message = "The WinRT backend has no adapter: the app does not run on Windows 10 2004 or later, or was built without its Windows target" )] - public static partial void LogIgnoreDataSectionReservedType(this ILogger logger, AdTypes adType); + public static partial void LogNotOnWindows(this ILogger logger); - [LoggerMessage(Level = LogLevel.Warning, Message = "Publisher status changed to {Status} with {Error}")] - public static partial void LogPublisherChangedToError( - this ILogger logger, - BluetoothLEAdvertisementPublisherStatus status, - BluetoothError error - ); + [LoggerMessage( + EventId = 3001, + EventName = "ReservedSectionDropped", + Level = LogLevel.Warning, + Message = "Windows sets advertising data section {AdType} itself; the app's section is left out" + )] + public static partial void LogReservedSectionDropped(this ILogger logger, byte adType); + + [LoggerMessage( + EventId = 3002, + EventName = "HostedRequestFailed", + Level = LogLevel.Warning, + Message = "A central's request to a hosted service could not be answered" + )] + public static partial void LogHostedRequestFailed(this ILogger logger, Exception exception); } diff --git a/src/Darp.Ble.WinRT/PublicAPI.Shipped.txt b/src/Darp.Ble.WinRT/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/Darp.Ble.WinRT/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Darp.Ble.WinRT/PublicAPI.Unshipped.txt b/src/Darp.Ble.WinRT/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..ec8dc36f --- /dev/null +++ b/src/Darp.Ble.WinRT/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +#nullable enable +Darp.Ble.WinRT.WinRTBleManagerBuilderExtensions +static Darp.Ble.WinRT.WinRTBleManagerBuilderExtensions.AddWinRT(this Darp.Ble.BleManagerBuilder! builder) -> Darp.Ble.BleManagerBuilder! diff --git a/src/Darp.Ble.WinRT/WinAdvertisingSet.cs b/src/Darp.Ble.WinRT/WinAdvertisingSet.cs deleted file mode 100644 index e747e6c2..00000000 --- a/src/Darp.Ble.WinRT/WinAdvertisingSet.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Darp.Ble.Data; -using Darp.Ble.Gap; -using Darp.Ble.Implementation; - -namespace Darp.Ble.WinRT; - -/// The windows implementation of the advertising set -/// -public sealed class WinAdvertisingSet(BleBroadcaster broadcaster) : AdvertisingSet(broadcaster) -{ - /// - public override Task SetRandomAddressAsync(BleAddress randomAddress, CancellationToken cancellationToken = default) - { - throw new NotSupportedException(); - } - - /// - public override Task SetScanResponseDataAsync( - AdvertisingData scanResponseData, - CancellationToken cancellationToken = default - ) - { - throw new NotSupportedException(); - } -} diff --git a/src/Darp.Ble.WinRT/WinBleBroadcaster.cs b/src/Darp.Ble.WinRT/WinBleBroadcaster.cs deleted file mode 100644 index d517f2b6..00000000 --- a/src/Darp.Ble.WinRT/WinBleBroadcaster.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Collections.Concurrent; -using System.Runtime.InteropServices.WindowsRuntime; -using Darp.Ble.Data; -using Darp.Ble.Data.AssignedNumbers; -using Darp.Ble.Gap; -using Darp.Ble.Gatt.Server; -using Darp.Ble.Implementation; -using Darp.Ble.Utils; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.Advertisement; - -namespace Darp.Ble.WinRT; - -internal sealed class WinBleBroadcaster(WinBleDevice winBleDevice, ILogger logger) - : BleBroadcaster(winBleDevice, logger) -{ - private readonly WinBleDevice _winBleDevice = winBleDevice; - private readonly ConcurrentDictionary _publishers = new(); - - public override bool IsAdvertising => _publishers.All(x => x.Key.IsAdvertising); - - protected override Task CreateAdvertisingSetAsyncCore( - AdvertisingParameters? parameters, - AdvertisingData? data, - AdvertisingData? scanResponseData, - CancellationToken cancellationToken - ) - { - return Task.FromResult(new WinAdvertisingSet(this)); - } - - protected override Task StartAdvertisingCoreAsync( - IReadOnlyCollection<(IAdvertisingSet AdvertisingSet, TimeSpan Duration, byte NumberOfEvents)> advertisingSets, - CancellationToken cancellationToken - ) - { - List disposables = []; - foreach ((IAdvertisingSet advertisingSet, TimeSpan duration, int numberOfEvents) in advertisingSets) - { - if (_publishers.ContainsKey(advertisingSet)) - continue; - if (_winBleDevice.Capabilities.HasFlag(Capabilities.Peripheral)) - { - var peripheral = (WinBlePeripheral)_winBleDevice.Peripheral; - if (peripheral.Services.Count > 0) - { - IAsyncDisposable d = peripheral.AdvertiseServices(advertisingSet); - if (duration > TimeSpan.Zero) - { - var source = new CancellationTokenSource(duration); - source.Token.Register(async () => await d.DisposeAsync()); - } - if (numberOfEvents > 0) - { - ArgumentOutOfRangeException.ThrowIfGreaterThan(numberOfEvents, 0); - } - disposables.Add(d); - continue; - } - } - var publisher = new BluetoothLEAdvertisementPublisher(); - - foreach ((AdTypes type, ReadOnlyMemory bytes) in advertisingSet.Data) - { - // Reserved types: https://learn.microsoft.com/en-us/uwp/api/windows.devices.bluetooth.advertisement.bluetoothleadvertisementpublisher?view=winrt-22621 - if ( - type - is AdTypes.Flags - or AdTypes.IncompleteListOf16BitServiceOrServiceClassUuids - or AdTypes.CompleteListOf16BitServiceOrServiceClassUuids - or AdTypes.IncompleteListOf32BitServiceOrServiceClassUuids - or AdTypes.CompleteListOf32BitServiceOrServiceClassUuids - or AdTypes.IncompleteListOf128BitServiceOrServiceClassUuids - or AdTypes.CompleteListOf128BitServiceOrServiceClassUuids - or AdTypes.ShortenedLocalName - or AdTypes.CompleteLocalName - or AdTypes.TxPowerLevel - or AdTypes.ClassOfDevice - or AdTypes.SimplePairingHashC192 - or AdTypes.SimplePairingRandomizerR192 - or AdTypes.SecurityManagerTkValue - or AdTypes.SecurityManagerOutOfBandFlags - or AdTypes.PeripheralConnectionIntervalRange - or AdTypes.ListOf16BitServiceSolicitationUuids - or AdTypes.ListOf32BitServiceSolicitationUuids - or AdTypes.ListOf128BitServiceSolicitationUuids - or AdTypes.ServiceData16BitUuid - or AdTypes.ServiceData32BitUuid - or AdTypes.ServiceData128BitUuid - or AdTypes.PublicTargetAddress - or AdTypes.RandomTargetAddress - or AdTypes.Appearance - or AdTypes.AdvertisingInterval - or AdTypes.LeBluetoothDeviceAddress - or AdTypes.LeRole - or AdTypes.SimplePairingHashC256 - or AdTypes.SimplePairingRandomizerR256 - or AdTypes.ThreeDInformationData - ) - { - Logger?.LogIgnoreDataSectionReservedType(type); - continue; - } - publisher.Advertisement.DataSections.Add( - new BluetoothLEAdvertisementDataSection((byte)type, bytes.ToArray().AsBuffer()) - ); - } - - if (!advertisingSet.Parameters.Type.HasFlag(BleEventType.Legacy)) - { - publisher.UseExtendedAdvertisement = true; - } - if (advertisingSet.Parameters.AdvertisingTxPower is not TxPowerLevel.NotAvailable) - { - publisher.IncludeTransmitPowerLevel = true; - publisher.PreferredTransmitPowerLevelInDBm = (short)advertisingSet.Parameters.AdvertisingTxPower; - } - - IAsyncDisposable disposable = AsyncDisposable.Create(publisher, state => state.Stop()); - // //publisher.UseExtendedAdvertisement = true; - // //publisher.IncludeTransmitPowerLevel = true; - publisher.StatusChanged += async (_, args) => - { - if (args.Error is BluetoothError.Success) - return; - Logger?.LogPublisherChangedToError(args.Status, args.Error); - await disposable.DisposeAsync().ConfigureAwait(false); - }; - if (_publishers.TryAdd(advertisingSet, publisher)) - { - publisher.Start(); - disposables.Add(disposable); - } - } - IAsyncDisposable combinedDisposable = AsyncDisposable.Create( - disposables, - async x => - { - foreach (IAsyncDisposable asyncDisposable in x) - { - await asyncDisposable.DisposeAsync().ConfigureAwait(false); - } - } - ); - return Task.FromResult(combinedDisposable); - } - - protected override Task StopAdvertisingCoreAsync( - IReadOnlyCollection advertisingSets, - CancellationToken cancellationToken - ) - { - foreach (IAdvertisingSet advertisingSet in advertisingSets) - { - if (_publishers.TryGetValue(advertisingSet, out BluetoothLEAdvertisementPublisher? publisher)) - { - publisher.Stop(); - } - } - return Task.FromResult(true); - } -} diff --git a/src/Darp.Ble.WinRT/WinBleCentral.cs b/src/Darp.Ble.WinRT/WinBleCentral.cs deleted file mode 100644 index bc831dba..00000000 --- a/src/Darp.Ble.WinRT/WinBleCentral.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Reactive.Linq; -using Darp.Ble.Data; -using Darp.Ble.Gatt.Server; -using Darp.Ble.Implementation; -using Darp.Ble.WinRT.Gatt.Server; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; - -namespace Darp.Ble.WinRT; - -/// -internal sealed class WinBleCentral(BleDevice device, ILogger logger) : BleCentral(device, logger) -{ - /// - protected override IObservable ConnectToPeripheralCore( - BleAddress address, - BleConnectionParameters connectionParameters, - BleObservationParameters observationParameters - ) - { - return Observable.Create( - async (observer, cancellationToken) => - { - BluetoothLEDevice? winDev = await BluetoothLEDevice - .FromBluetoothAddressAsync( - address.Value, - address.Type switch - { - BleAddressType.Public or BleAddressType.PublicIdentity => BluetoothAddressType.Public, - BleAddressType.NotAvailable => BluetoothAddressType.Unspecified, - _ => BluetoothAddressType.Random, - } - ) - .AsTask(cancellationToken) - .ConfigureAwait(false); - if (winDev is null) - { - observer.OnError(new Exception("PeripheralConnection: Failed!")); - return; - } - observer.OnNext(new WinGattServerPeer(this, winDev, ServiceProvider.GetLogger())); - } - ); - } -} diff --git a/src/Darp.Ble.WinRT/WinBleDevice.cs b/src/Darp.Ble.WinRT/WinBleDevice.cs deleted file mode 100644 index 9b390b9d..00000000 --- a/src/Darp.Ble.WinRT/WinBleDevice.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Darp.Ble.Data; -using Darp.Ble.Data.AssignedNumbers; -using Darp.Ble.Implementation; -using Windows.Devices.Bluetooth; - -namespace Darp.Ble.WinRT; - -/// Provides windows specific implementation of a ble device -internal sealed class WinBleDevice(IServiceProvider serviceProvider, string name) - : BleDevice(serviceProvider, serviceProvider.GetLogger()) -{ - private BluetoothAdapter? _adapter; - public override BleAddress RandomAddress => - _adapter?.BluetoothAddress is null - ? BleAddress.NotAvailable - : new BleAddress(BleAddressType.Random, (UInt48)_adapter.BluetoothAddress); - - protected override Task SetRandomAddressAsyncCore(BleAddress randomAddress, CancellationToken cancellationToken) - { - throw new NotSupportedException(); - } - - /// - protected override async Task InitializeAsyncCore(CancellationToken cancellationToken) - { - _adapter = await BluetoothAdapter.GetDefaultAsync(); - if (!_adapter.IsLowEnergySupported) - return InitializeResult.DeviceVersionUnsupported; - Observer = new WinBleObserver(this, ServiceProvider.GetLogger()); - if (_adapter.IsCentralRoleSupported) - Central = new WinBleCentral(this, ServiceProvider.GetLogger()); - if (_adapter.IsAdvertisementOffloadSupported) - Broadcaster = new WinBleBroadcaster(this, ServiceProvider.GetLogger()); - if (_adapter.IsPeripheralRoleSupported) - Peripheral = new WinBlePeripheral(this, ServiceProvider.GetLogger()); - return InitializeResult.Success; - } - - /// - public override string? Name { get; set; } = name; - - /// - public override AppearanceValues Appearance { get; set; } = AppearanceValues.Computer; - - /// - public override string Identifier => BleDeviceIdentifiers.WinRT; -} diff --git a/src/Darp.Ble.WinRT/WinBleFactory.cs b/src/Darp.Ble.WinRT/WinBleFactory.cs deleted file mode 100644 index cfc06d53..00000000 --- a/src/Darp.Ble.WinRT/WinBleFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Runtime.Versioning; -#if !WINDOWS -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -#endif - -namespace Darp.Ble.WinRT; - -/// Search for the default windows ble device -/// If used in an environment which is not build for windows, this becomes a NoOp -[SupportedOSPlatform("windows")] -public sealed partial class WinBleFactory : IBleFactory -{ - /// The name of the resulting device - public string Name { get; set; } = "Windows"; - - /// - public IEnumerable EnumerateDevices(IServiceProvider serviceProvider) - { -#if WINDOWS - yield return new WinBleDevice(serviceProvider, Name); -#else - var logger = serviceProvider.GetService>(); - if (logger is not null) - LogNoWindowsUnintentionalUse(logger, nameof(WinBleFactory), GetTargetFrameworkMoniker()); - yield break; -#endif - } - -#if !WINDOWS - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Using the {FactoryName} while not compiled for Windows is probably unintended! You compiled for {TargetFrameworkName}" - )] - private static partial void LogNoWindowsUnintentionalUse( - ILogger logger, - string factoryName, - string? targetFrameworkName - ); - - private static string? GetTargetFrameworkMoniker() - { - Assembly assembly = typeof(WinBleFactory).Assembly; - AssemblyMetadataAttribute? meta = assembly - .GetCustomAttributes() - .FirstOrDefault(a => a.Key.Equals("TargetFramework", StringComparison.Ordinal)); - return meta?.Value; - } -#endif -} diff --git a/src/Darp.Ble.WinRT/WinBleFactoryExtensions.cs b/src/Darp.Ble.WinRT/WinBleFactoryExtensions.cs deleted file mode 100644 index ab28b172..00000000 --- a/src/Darp.Ble.WinRT/WinBleFactoryExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Runtime.Versioning; - -namespace Darp.Ble.WinRT; - -/// Extension methods for easier addition of a -public static class WinBleFactoryExtensions -{ - /// Add a new and configure it. - /// An optional callback to configure the factory - /// The callback to configure the factory - /// The - /// If used in an environment which is not build for windows, this becomes a NoOp - [SupportedOSPlatform("windows")] - public static BleManagerBuilder AddWinRT(this BleManagerBuilder builder, Action? configure = null) - { - ArgumentNullException.ThrowIfNull(builder); - return builder.Add(configure); - } -} diff --git a/src/Darp.Ble.WinRT/WinBleObserver.cs b/src/Darp.Ble.WinRT/WinBleObserver.cs deleted file mode 100644 index e7351e71..00000000 --- a/src/Darp.Ble.WinRT/WinBleObserver.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reactive; -using System.Reactive.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Darp.Ble.Data; -using Darp.Ble.Data.AssignedNumbers; -using Darp.Ble.Exceptions; -using Darp.Ble.Gap; -using Darp.Ble.Implementation; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.Advertisement; -using Windows.Foundation; - -namespace Darp.Ble.WinRT; - -/// -internal sealed class WinBleObserver(BleDevice device, ILogger logger) : BleObserver(device, logger) -{ - private BluetoothLEAdvertisementWatcher? _watcher; - private IDisposable? _observableSubscription; - - [MemberNotNull(nameof(_watcher))] - private void CreateScanners(ScanType mode) - { - _watcher = new BluetoothLEAdvertisementWatcher - { - ScanningMode = mode switch - { - ScanType.Passive => BluetoothLEScanningMode.Passive, - ScanType.Active => BluetoothLEScanningMode.Active, - _ => throw new ArgumentOutOfRangeException(nameof(mode)), - }, - AllowExtendedAdvertisements = true, - }; - // Subscription needed for the watcher to start (observable will only subscribe later) - _watcher.Received += (_, _) => { }; - _watcher.Stopped += (_, _) => { }; - } - - protected override Task StartObservingAsyncCore(CancellationToken cancellationToken) - { - CreateScanners(Parameters.ScanType); - _watcher.Start(); - if (_watcher.Status is BluetoothLEAdvertisementWatcherStatus.Aborted) - { - throw new BleObservationStartException( - this, - $"Watcher status is '{_watcher.Status}' but should be 'Created'.\nTry restarting the bluetooth adapter!" - ); - } - _watcher.Stopped += async (_, args) => - { - if (args.Error is BluetoothError.Success) - return; - _observableSubscription?.Dispose(); - _watcher = null; - await OnErrorAsync( - new BleObservationException(this, $"Watcher stopped with error {args.Error}", innerException: null) - ) - .ConfigureAwait(false); - }; - _observableSubscription = Observable - .FromEventPattern< - TypedEventHandler, - BluetoothLEAdvertisementWatcher, - BluetoothLEAdvertisementReceivedEventArgs - >(addHandler => _watcher.Received += addHandler, removeHandler => _watcher.Received -= removeHandler) - .Select(adv => OnAdvertisementReport(this, adv)) - .Subscribe(adv => OnNext(adv)); - return Task.CompletedTask; - } - - protected override Task StopObservingAsyncCore() - { - _observableSubscription?.Dispose(); - _watcher?.Stop(); - _watcher = null; - return Task.CompletedTask; - } - - protected override async ValueTask DisposeAsyncCore() - { - _observableSubscription?.Dispose(); - _watcher?.Stop(); - await base.DisposeAsyncCore().ConfigureAwait(false); - } - - private static GapAdvertisement OnAdvertisementReport( - BleObserver bleObserver, - EventPattern gapEvt - ) - { - BluetoothLEAdvertisementReceivedEventArgs eventArgs = gapEvt.EventArgs; - - BleEventType advertisementType = eventArgs.AdvertisementType switch - { - BluetoothLEAdvertisementType.ConnectableUndirected => BleEventType.AdvInd, - BluetoothLEAdvertisementType.ConnectableDirected => BleEventType.AdvDirectInd, - BluetoothLEAdvertisementType.ScannableUndirected => BleEventType.AdvScanInd, - BluetoothLEAdvertisementType.NonConnectableUndirected => BleEventType.AdvNonConnInd, - // A legacy scan response answers ADV_IND (connectable) or ADV_SCAN_IND, both scannable - BluetoothLEAdvertisementType.ScanResponse => BleEventType.Legacy - | BleEventType.Scannable - | BleEventType.ScanResponse - | (eventArgs.IsConnectable ? BleEventType.Connectable : BleEventType.None), - _ => GetExtendedEventType(eventArgs), - }; - - (AdTypes, ReadOnlyMemory)[] pduData = eventArgs - .Advertisement.DataSections.Select(section => - ((AdTypes)section.DataType, (ReadOnlyMemory)section.Data.ToArray()) - ) - .ToArray(); - - GapAdvertisement advertisement = GapAdvertisement.FromExtendedAdvertisingReport( - bleObserver, - eventArgs.Timestamp, - advertisementType, - BleHelper.GetBleAddress(eventArgs.BluetoothAddress, eventArgs.BluetoothAddressType), - Physical.NotAvailable, - Physical.NotAvailable, - AdvertisingSId.NoAdIProvided, - (TxPowerLevel?)eventArgs.TransmitPowerLevelInDBm ?? TxPowerLevel.NotAvailable, - (Rssi)eventArgs.RawSignalStrengthInDBm, - PeriodicAdvertisingInterval.NoPeriodicAdvertising, - new BleAddress(BleAddressType.NotAvailable, (UInt48)0x000000000000), - AdvertisingData.From(pduData) - ); - - return advertisement; - } - - /// Extended advertisements have no single PDU type, Windows reports their properties separately - private static BleEventType GetExtendedEventType(BluetoothLEAdvertisementReceivedEventArgs eventArgs) - { - var eventType = BleEventType.None; - if (eventArgs.IsConnectable) - eventType |= BleEventType.Connectable; - if (eventArgs.IsScannable) - eventType |= BleEventType.Scannable; - if (eventArgs.IsDirected) - eventType |= BleEventType.Directed; - if (eventArgs.IsScanResponse) - eventType |= BleEventType.ScanResponse; - return eventType; - } -} diff --git a/src/Darp.Ble.WinRT/WinBlePeripheral.cs b/src/Darp.Ble.WinRT/WinBlePeripheral.cs deleted file mode 100644 index 763139a1..00000000 --- a/src/Darp.Ble.WinRT/WinBlePeripheral.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Darp.Ble.Data; -using Darp.Ble.Gatt.Client; -using Darp.Ble.Gatt.Server; -using Darp.Ble.Implementation; -using Darp.Ble.Utils; -using Darp.Ble.WinRT.Gatt; -using Microsoft.Extensions.Logging; -using Windows.Devices.Bluetooth; -using Windows.Devices.Bluetooth.GenericAttributeProfile; - -namespace Darp.Ble.WinRT; - -internal sealed class WinBlePeripheral(WinBleDevice device, ILogger logger) - : BlePeripheral(device, logger) -{ - protected override GattClientService AddServiceCore(BleUuid uuid, bool isPrimary) - { - GattServiceProviderResult result = GattServiceProvider.CreateAsync(uuid.Value).GetResults(); - if (result.Error is not BluetoothError.Success) - throw new Exception("Nope"); - GattServiceProvider provider = result.ServiceProvider; - return new WinGattClientService(this, provider, ServiceProvider.GetLogger()); - } - - internal IGattClientPeer GetOrRegisterSession(GattSession gattSession) - { - BleAddress address = BleAddress.Parse(gattSession.DeviceId.Id[^17..], provider: null); - if (PeerDevices.TryGetValue(address, out IGattClientPeer? clientPeer) && clientPeer.IsConnected) - { - return clientPeer; - } - clientPeer = new WinGattClientPeer(this, gattSession, address, ServiceProvider.GetLogger()); - OnConnectedCentral(clientPeer); - return clientPeer; - } - - public IAsyncDisposable AdvertiseServices(IAdvertisingSet advertisingSet) - { - List disposables = []; - foreach (IGattClientService value in Services) - { - if (value is WinGattClientService service) - disposables.Add(service.Advertise(advertisingSet)); - } - return AsyncDisposable.Create( - disposables, - async list => - { - foreach (IAsyncDisposable disposable in list) - { - await disposable.DisposeAsync().ConfigureAwait(false); - } - } - ); - } -} diff --git a/src/Darp.Ble.WinRT/WinRTBackend.cs b/src/Darp.Ble.WinRT/WinRTBackend.cs new file mode 100644 index 00000000..dc3588e2 --- /dev/null +++ b/src/Darp.Ble.WinRT/WinRTBackend.cs @@ -0,0 +1,24 @@ +using Darp.Ble.Backends; +using Microsoft.Extensions.Logging; + +namespace Darp.Ble.WinRT; + +/// Windows' Bluetooth stack: one adapter, the system's default +internal sealed class WinRTBackend : IBleBackend +{ + public string Name => BleBackends.WinRT; + + public Task> EnumerateAdaptersAsync( + BleBackendContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); +#if WINDOWS + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041)) + return Task.FromResult>([new WinRTAdapterSource()]); +#endif + context.LoggerFactory.CreateLogger().LogNotOnWindows(); + return Task.FromResult>([]); + } +} diff --git a/src/Darp.Ble.WinRT/WinRTBleManagerBuilderExtensions.cs b/src/Darp.Ble.WinRT/WinRTBleManagerBuilderExtensions.cs new file mode 100644 index 00000000..fd175edd --- /dev/null +++ b/src/Darp.Ble.WinRT/WinRTBleManagerBuilderExtensions.cs @@ -0,0 +1,14 @@ +namespace Darp.Ble.WinRT; + +/// Registers the Windows backend +public static class WinRTBleManagerBuilderExtensions +{ + /// Adds Windows' default Bluetooth adapter. On other platforms, or a build without the Windows target, it adds no adapter + /// The builder + /// The builder + public static BleManagerBuilder AddWinRT(this BleManagerBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.AddBackend(new WinRTBackend()); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Connections/WinRTCentralDriver.cs b/src/Darp.Ble.WinRT/Windows/Connections/WinRTCentralDriver.cs new file mode 100644 index 00000000..96b265c0 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Connections/WinRTCentralDriver.cs @@ -0,0 +1,67 @@ +using Darp.Ble.Backends; +using Darp.Ble.Connections; +using Darp.Ble.Data; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace Darp.Ble.WinRT.Connections; + +/// +/// Connects through a GATT session that keeps the connection up. Windows chooses the connection parameters; +/// the connection exists once the session is active +/// +internal sealed class WinRTCentralDriver : ICentralDriver +{ + public async Task ConnectAsync( + BleAddress address, + BleConnectionParameters parameters, + IConnectionSink sink, + CancellationToken cancellationToken + ) + { + BluetoothLEDevice? device = await BluetoothLEDevice + .FromBluetoothAddressAsync(address.Value, WinRTMapping.ToWindows(address.Type)) + .AsTask(cancellationToken) + .ConfigureAwait(false); + if (device is null) + throw new BleConnectException(BleConnectErrorKind.Unreachable, $"Windows does not know a device {address}"); + GattSession? session = null; + try + { + session = await GattSession + .FromDeviceIdAsync(device.BluetoothDeviceId) + .AsTask(cancellationToken) + .ConfigureAwait(false); + var active = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnStatus(GattSession sender, GattSessionStatusChangedEventArgs args) + { + if (args.Status is GattSessionStatus.Active) + active.TrySetResult(); + } + session.SessionStatusChanged += OnStatus; + try + { + session.MaintainConnection = true; + if (session.SessionStatus is GattSessionStatus.Active) + active.TrySetResult(); + // Windows keeps trying; the caller decides how long + await active.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + session.SessionStatusChanged -= OnStatus; + } + return new WinRTConnectionDriver(device, session, sink); + } + catch + { + if (session is not null) + { + session.MaintainConnection = false; + session.Dispose(); + } + device.Dispose(); + throw; + } + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Connections/WinRTCentralLink.cs b/src/Darp.Ble.WinRT/Windows/Connections/WinRTCentralLink.cs new file mode 100644 index 00000000..da8f694b --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Connections/WinRTCentralLink.cs @@ -0,0 +1,13 @@ +using Darp.Ble.Backends; +using Darp.Ble.Data; + +namespace Darp.Ble.WinRT.Connections; + +/// A central's connection to this app's services: one per device for as long as its sessions last +internal sealed class WinRTCentralLink(string deviceId, BleAddress address) : ICentralConnectionDriver +{ + /// Windows' id of the device; opaque + public string DeviceId { get; } = deviceId; + + public BleAddress Address { get; } = address; +} diff --git a/src/Darp.Ble.WinRT/Windows/Connections/WinRTConnectionDriver.cs b/src/Darp.Ble.WinRT/Windows/Connections/WinRTConnectionDriver.cs new file mode 100644 index 00000000..c30c1d90 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Connections/WinRTConnectionDriver.cs @@ -0,0 +1,190 @@ +using Darp.Ble.Backends; +using Darp.Ble.Connections; +using Darp.Ble.Gatt; +using Darp.Ble.WinRT.Gatt; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; +using Windows.Devices.Enumeration; + +namespace Darp.Ble.WinRT.Connections; + +/// +/// This app's session with a peripheral. Ending it releases the session; Windows keeps the physical link while +/// another app uses the device +/// +internal sealed class WinRTConnectionDriver : IConnectionDriver +{ + private const int DefaultMtu = 23; + + // Twice the longest delay measured from the session going active to Windows reporting a larger MTU + private static readonly TimeSpan MtuWait = TimeSpan.FromMilliseconds(500); + + private readonly BluetoothLEDevice _device; + private readonly GattSession _session; + private readonly IConnectionSink _sink; + + // One object per attribute handle, so every discovery returns the same ones and notifications find them + private readonly AttributeWrappers _services; + private readonly AttributeWrappers _characteristics; + private readonly Dictionary _descriptors = []; + private bool _accessGranted; + private int _ended; + + public WinRTConnectionDriver(BluetoothLEDevice device, GattSession session, IConnectionSink sink) + { + _device = device; + _session = session; + _sink = sink; + // A service that arrives after the end is released; a characteristic holds nothing to release + _services = new(s => s.AttributeHandle, s => new WinRTRemoteService(this, s), s => s.Dispose()); + _characteristics = new(c => c.AttributeHandle, c => new WinRTRemoteCharacteristic(this, c), _ => { }); + _device.ConnectionStatusChanged += OnConnectionStatusChanged; + _session.SessionStatusChanged += OnSessionStatusChanged; + _session.MaxPduSizeChanged += OnMaxPduSizeChanged; + // The link may have ended before the handlers were attached + if ( + _device.ConnectionStatus is BluetoothConnectionStatus.Disconnected + || _session.SessionStatus is GattSessionStatus.Closed + ) + LinkDown(DisconnectReason.RemoteRequest); + } + + public IConnectionSink Sink => _sink; + + /// + /// Waits briefly for the MTU Windows exchanges itself, about 0.2 s after the session is active, so the connection + /// starts with it. Windows reports only a larger MTU: a peer that keeps the default costs the whole wait, and an + /// MTU that grows after the wait still reaches the sink + /// + public async Task ExchangeMtuAsync(int clientMtu, GattRequest request) + { + var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnChanged(GattSession sender, object args) => changed.TrySetResult(); + _session.MaxPduSizeChanged += OnChanged; + try + { + // Nothing is sent, so the wait ends with the request + if (_session.MaxPduSize <= DefaultMtu) + await changed.Task.WaitAsync(MtuWait, request.HandOffToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // The peer keeps the default MTU, or Windows exchanges later + } + finally + { + _session.MaxPduSizeChanged -= OnChanged; + } + return _session.MaxPduSize; + } + + public async Task> DiscoverServicesAsync(GattRequest request) + { + await EnsureAccessAsync(request).ConfigureAwait(false); + GattDeviceServicesResult result = await NativeCall + .RunAsync(request, () => _device.GetGattServicesAsync(BluetoothCacheMode.Uncached)) + .ConfigureAwait(false); + ThrowIfFailed(result.Status, result.ProtocolError, "discover services"); + return _services.Wrap(result.Services) ?? throw Ended(); + } + + /// Windows asks the user once whether the app may use the device + private async Task EnsureAccessAsync(GattRequest request) + { + if (_accessGranted) + return; + // Nothing is sent yet, so the wait for the user's answer ends with the request + DeviceAccessStatus status = await _device + .RequestAccessAsync() + .AsTask(request.HandOffToken) + .ConfigureAwait(false); + if (status is not DeviceAccessStatus.Allowed) + throw new BleException($"Windows does not allow access to the device: {status}"); + _accessGranted = true; + } + + /// Wraps discovered characteristics; their wrappers receive the notifications + public IReadOnlyList WrapCharacteristics( + IReadOnlyList characteristics + ) => _characteristics.Wrap(characteristics) ?? throw Ended(); + + /// A discovery Windows answered after this connection ended; the core reports the end instead + private static BleException Ended() => new("The connection ended before Windows answered"); + + /// Only discovery, one request at a time, asks for descriptors; disposal does not need them + public WinRTRemoteDescriptor GetDescriptor(GattDescriptor descriptor) + { + if (_descriptors.TryGetValue(descriptor.AttributeHandle, out WinRTRemoteDescriptor? known)) + return known; + var created = new WinRTRemoteDescriptor(this, descriptor); + _descriptors.Add(descriptor.AttributeHandle, created); + return created; + } + + private void OnConnectionStatusChanged(BluetoothLEDevice sender, object args) + { + if (sender.ConnectionStatus is BluetoothConnectionStatus.Disconnected) + LinkDown(DisconnectReason.RemoteRequest); + } + + private void OnMaxPduSizeChanged(GattSession sender, object args) => _sink.OnMtuChanged(sender.MaxPduSize); + + private void OnSessionStatusChanged(GattSession sender, GattSessionStatusChangedEventArgs args) + { + if (args.Status is GattSessionStatus.Closed) + LinkDown(DisconnectReason.RemoteRequest); + } + + /// + /// Fails with the peer's ATT error, the lost link or Windows' refusal. A device Windows cannot reach ends the + /// connection: it is reported first, so the core fails the request as a disconnect + /// + public void ThrowIfFailed(GattCommunicationStatus status, byte? protocolError, string action) + { + switch (status) + { + case GattCommunicationStatus.Success: + return; + case GattCommunicationStatus.ProtocolError: + throw new GattException( + (GattProtocolStatus)(protocolError ?? (byte)GattProtocolStatus.UnlikelyError), + $"The peer answered {action} with {protocolError}" + ); + case GattCommunicationStatus.Unreachable: + LinkDown(DisconnectReason.ConnectionTimeout); + throw new BleException($"Windows could not reach the device to {action}"); + default: + // AccessDenied: Windows does not let this app use the device. No lost link, so the connection stays + throw new BleException( + $"Windows refused to {action}: {status}", + isTransient: false, + nativeCode: (int)status, + innerException: null + ); + } + } + + /// Reports the end once. Windows' status events tell no reason; the peer or the link ended the connection + private void LinkDown(DisconnectReason reason) + { + if (Interlocked.Exchange(ref _ended, 1) == 0) + _sink.OnDisconnected(new DisconnectInfo(reason)); + } + + /// Releases this app's part without waiting for the physical link + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref _ended, 1); + _device.ConnectionStatusChanged -= OnConnectionStatusChanged; + _session.SessionStatusChanged -= OnSessionStatusChanged; + _session.MaxPduSizeChanged -= OnMaxPduSizeChanged; + foreach (WinRTRemoteCharacteristic characteristic in _characteristics.End()) + characteristic.Detach(); + foreach (WinRTRemoteService service in _services.End()) + service.Dispose(); + _session.MaintainConnection = false; + _session.Dispose(); + _device.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Connections/WinRTPeripheralDriver.cs b/src/Darp.Ble.WinRT/Windows/Connections/WinRTPeripheralDriver.cs new file mode 100644 index 00000000..af9a3861 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Connections/WinRTPeripheralDriver.cs @@ -0,0 +1,316 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using Darp.Ble.Backends; +using Darp.Ble.Connections; +using Darp.Ble.Data; +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.WinRT.Gatt; +using Microsoft.Extensions.Logging; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; +using Windows.Devices.Radios; + +namespace Darp.Ble.WinRT.Connections; + +/// +/// Hosting on Windows. Windows owns the GAP, GATT, Device Information and Scan Parameters services, so adding +/// those fails. Windows serves a service only while it advertises it, so added services are live while a connectable +/// advertising set runs. A central becomes known through its first request or subscription; Windows has no event for a +/// connection alone, and this app cannot take a central's link down, only end its own part. When the radio turns off and +/// on again, Windows advertises the services again by itself +/// +internal sealed class WinRTPeripheralDriver(Radio radio, ILogger logger) : IPeripheralDriver, IDisposable +{ + private static readonly HashSet Reserved = + [ + BleUuid.FromUInt16(0x1800), + BleUuid.FromUInt16(0x1801), + BleUuid.FromUInt16(0x180A), + BleUuid.FromUInt16(0x1813), + ]; + + private readonly Radio _radio = radio; + private readonly Lock _lock = new(); + + // Resolving and announcing a central, and ending it, happen under this lock: no callback sees a central the core + // does not know yet or no longer knows + private readonly Lock _announcing = new(); + + // Services are added and advertised one change at a time, through their whole native calls + private readonly SemaphoreSlim _adding = new(1, 1); + + // Subscription lists are applied one at a time, so an older list cannot win over a newer one + private readonly SemaphoreSlim _subscribing = new(1, 1); + private readonly List _services = []; + private readonly CentralSessions _sessions = new(); + private readonly SubscriptionSnapshots _subscriptions = new(); + private readonly Dictionary _addresses = new(StringComparer.Ordinal); + private IGattServerSink? _sink; + private int _connectableSets; + + public ILogger Logger { get; } = logger; + + public IGattServerSink Sink => + _sink ?? throw new InvalidOperationException("The core did not attach the peripheral"); + + public void Attach(IGattServerSink sink) => _sink = sink; + + public async Task AddServiceAsync(GattServiceDefinition service, CancellationToken cancellationToken) + { + if (Reserved.Contains(service.Uuid)) + throw new InvalidOperationException($"Windows serves the service {service.Uuid} itself"); + if (!service.IsPrimary) + throw new NotSupportedException("Windows hosts primary services only"); + await _adding.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // An advertised service starts advertising at once, which waits silently while the radio is off + if (_connectableSets > 0) + WinRTRadio.ThrowIfOff(_radio); + WinRTHostedService? hosted = await WinRTHostedService + .CreateAsync(this, service, cancellationToken) + .ConfigureAwait(false); + try + { + if (_connectableSets > 0) + await hosted.StartAdvertisingAsync(cancellationToken).ConfigureAwait(false); + lock (_lock) + _services.Add(hosted); + hosted = null; + } + finally + { + // Not added: nothing of it stays + hosted?.Dispose(); + } + } + finally + { + _adding.Release(); + } + } + + /// Whether any service is added; connectable advertising needs one + public bool HasServices + { + get + { + lock (_lock) + return _services.Count > 0; + } + } + + /// + /// A connectable advertising set started or stopped: the services are advertised while one runs. The count changes + /// only when starting succeeded; a failure stops the services it started + /// + public async Task SetAdvertisedAsync(bool advertised, CancellationToken cancellationToken) + { + await _adding.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (advertised) + WinRTRadio.ThrowIfOff(_radio); + int sets = Math.Max(0, _connectableSets + (advertised ? 1 : -1)); + WinRTHostedService[] services; + lock (_lock) + services = [.. _services]; + if (sets > 0 && _connectableSets == 0) + await StartAdvertisingAsync(services, cancellationToken).ConfigureAwait(false); + else if (sets == 0) + { + foreach (WinRTHostedService service in services) + service.StopAdvertising(); + } + _connectableSets = sets; + } + finally + { + _adding.Release(); + } + } + + private static async Task StartAdvertisingAsync(WinRTHostedService[] services, CancellationToken cancellationToken) + { + List started = []; + try + { + foreach (WinRTHostedService service in services) + { + await service.StartAdvertisingAsync(cancellationToken).ConfigureAwait(false); + started.Add(service); + } + } + catch + { + foreach (WinRTHostedService service in started) + service.StopAdvertising(); + throw; + } + } + + public async ValueTask NotifyAsync( + ICentralConnectionDriver central, + GattCharacteristicDefinition characteristic, + ReadOnlyMemory value, + CancellationToken cancellationToken + ) + { + var link = (WinRTCentralLink)central; + cancellationToken.ThrowIfCancellationRequested(); + GattLocalCharacteristic? native; + lock (_lock) + native = _services + .Select(service => service.Find(characteristic)) + .FirstOrDefault(found => found is not null); + if (native is null || !_subscriptions.IsSubscribed(characteristic, link)) + return; + // The client of this central right now; a session that closed is no longer listed + GattSubscribedClient? client = native.SubscribedClients.FirstOrDefault(c => + string.Equals(c.Session.DeviceId.Id, link.DeviceId, StringComparison.Ordinal) + ); + if (client is null) + return; + // Longer values are cut to what the client takes, as ATT does + int length = Math.Min(value.Length, client.MaxNotificationSize); + GattClientNotificationResult result = await native + .NotifyValueAsync(value[..length].ToArray().AsBuffer(), client) + .AsTask() + .ConfigureAwait(false); + if (result.Status is not GattCommunicationStatus.Success) + throw new BleException($"Windows could not notify the central: {result.Status}"); + } + + public void SetGapIdentity(string deviceName, AppearanceValues appearance) => + throw new NotSupportedException("Windows serves the GAP service itself"); + + /// Ends this app's connection to the central; Windows may keep the link for other apps + public Task DisconnectAsync(ICentralConnectionDriver central, CancellationToken cancellationToken) + { + var link = (WinRTCentralLink)central; + lock (_announcing) + { + if (_sessions.End(link)) + End(link, new DisconnectInfo(DisconnectReason.LocalRequest)); + } + return Task.CompletedTask; + } + + /// The central of a session; announced to the core before its first request. Null for a session that closed + public async Task ResolveAsync(GattSession session) + { + string deviceId = session.DeviceId.Id; + BleAddress address = await AddressOfAsync(deviceId).ConfigureAwait(false); + lock (_announcing) + { + (WinRTCentralLink? link, bool isNewCentral, bool isNewSession) = _sessions.Resolve( + deviceId, + session, + () => new WinRTCentralLink(deviceId, address) + ); + if (link is null) + return null; + if (isNewCentral) + Sink.OnCentralConnected(link); + if (isNewSession) + { + session.SessionStatusChanged += OnSessionStatusChanged; + // It may have closed before the handler was attached + if (session.SessionStatus is GattSessionStatus.Closed) + { + Close(session); + return null; + } + } + return link; + } + } + + /// Windows reports a new list of subscribed clients; the core learns the changes against the list now + public async Task OnSubscribedClientsChangedAsync( + GattCharacteristicDefinition characteristic, + GattLocalCharacteristic native + ) + { + try + { + await _subscribing.WaitAsync().ConfigureAwait(false); + try + { + List links = []; + foreach (GattSubscribedClient client in native.SubscribedClients) + { + if (await ResolveAsync(client.Session).ConfigureAwait(false) is { } link) + links.Add(link); + } + (IReadOnlyList added, IReadOnlyList removed) = + _subscriptions.Update(characteristic, links); + foreach (WinRTCentralLink link in added) + Sink.OnSubscriptionChanged(link, characteristic, enabled: true); + foreach (WinRTCentralLink link in removed) + Sink.OnSubscriptionChanged(link, characteristic, enabled: false); + } + finally + { + _subscribing.Release(); + } + } + catch (Exception e) + { + // An event handler must not throw into Windows + Logger.LogHostedRequestFailed(e); + } + } + + private void OnSessionStatusChanged(GattSession sender, GattSessionStatusChangedEventArgs args) + { + if (args.Status is GattSessionStatus.Closed) + Close(sender); + } + + /// A session closed; ends its central when it was the last + private void Close(GattSession session) + { + session.SessionStatusChanged -= OnSessionStatusChanged; + lock (_announcing) + { + if (_sessions.Close(session) is { } link) + End(link, new DisconnectInfo(DisconnectReason.RemoteRequest)); + } + } + + private void End(WinRTCentralLink link, DisconnectInfo info) + { + _subscriptions.Forget(link); + Sink.OnCentralDisconnected(link, info); + } + + private async Task AddressOfAsync(string deviceId) + { + lock (_lock) + { + if (_addresses.TryGetValue(deviceId, out BleAddress? known)) + return known; + } + using BluetoothLEDevice? device = await BluetoothLEDevice.FromIdAsync(deviceId).AsTask().ConfigureAwait(false); + BleAddress address = device is null + ? BleAddress.NotAvailable + : WinRTMapping.ToAddress(device.BluetoothAddress, device.BluetoothAddressType); + lock (_lock) + _addresses[deviceId] = address; + return address; + } + + public void Dispose() + { + WinRTHostedService[] services; + lock (_lock) + { + services = [.. _services]; + _services.Clear(); + } + foreach (WinRTHostedService service in services) + service.Dispose(); + _adding.Dispose(); + _subscribing.Dispose(); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Gap/WinRTAdvertisingSetDriver.cs b/src/Darp.Ble.WinRT/Windows/Gap/WinRTAdvertisingSetDriver.cs new file mode 100644 index 00000000..48193243 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gap/WinRTAdvertisingSetDriver.cs @@ -0,0 +1,278 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.Gap; +using Darp.Ble.WinRT.Connections; +using Microsoft.Extensions.Logging; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.Advertisement; + +namespace Darp.Ble.WinRT.Gap; + +/// +/// One advertising set; the core serializes its calls and changes parameters only while it is stopped. Windows aborts a +/// running publisher for good when the radio turns off; the set reports that to the core +/// +internal sealed class WinRTAdvertisingSetDriver( + WinRTPeripheralDriver? peripheral, + AdvertisingParameters parameters, + AdvertisingData data, + IAdvertisingSetSink sink, + ILogger logger +) : IAdvertisingSetDriver +{ +#pragma warning disable CA2213 // The adapter owns the peripheral + private readonly WinRTPeripheralDriver? _peripheral = peripheral; +#pragma warning restore CA2213 + private readonly IAdvertisingSetSink _sink = sink; + private readonly ILogger _logger = logger; + + // The core serializes the calls, so the fields below need no lock + private AdvertisingParameters _parameters = parameters; + private AdvertisingData _data = data; + private BluetoothLEAdvertisementPublisher? _publisher; + private bool _servicesAdvertised; + + /// Windows chooses the address + public BleAddress RandomAddress => BleAddress.NotAvailable; + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (_parameters.Type.HasFlag(BleEventType.Connectable)) + { + if (_peripheral is not { HasServices: true } peripheral) + throw new NotSupportedException( + "Windows advertises connectable only the added services; add one first" + ); + if (_data.Count > 0) + throw new NotSupportedException( + "Windows advertises connectable only the added services, without data of the app's own" + ); + await peripheral.SetAdvertisedAsync(true, cancellationToken).ConfigureAwait(false); + _servicesAdvertised = true; + return; + } + // The core starts only a stopped set, so a publisher still here was aborted and reported; a late duplicate of + // that report must not reach the new run + _publisher = null; + Publish(await StartPublisherAsync(cancellationToken).ConfigureAwait(false)); + } + + /// A publisher with the current data, once Windows advertises it; a failed start leaves nothing running + private async Task StartPublisherAsync(CancellationToken cancellationToken) + { + BluetoothLEAdvertisementPublisher publisher = CreatePublisher(); + Task started = WaitForAsync( + publisher, + BluetoothLEAdvertisementPublisherStatus.Started, + cancellationToken + ); + publisher.Start(); + try + { + BluetoothLEAdvertisementPublisherStatusChangedEventArgs args = await started.ConfigureAwait(false); + if (args.Status is BluetoothLEAdvertisementPublisherStatus.Aborted) + throw Stopped(args.Error); + publisher.StatusChanged += OnRunningStatusChanged; + return publisher; + } + catch + { + // Windows may still start it after a cancel + if ( + publisher.Status + is BluetoothLEAdvertisementPublisherStatus.Waiting + or BluetoothLEAdvertisementPublisherStatus.Started + ) + publisher.Stop(); + throw; + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_servicesAdvertised && _peripheral is { } peripheral) + { + // Still this set's share until the stop went through, so a cancelled stop can be repeated + await peripheral.SetAdvertisedAsync(false, cancellationToken).ConfigureAwait(false); + _servicesAdvertised = false; + } + if (_publisher is { } publisher) + { + cancellationToken.ThrowIfCancellationRequested(); + _publisher = null; + await StopPublisherAsync(publisher).ConfigureAwait(false); + } + } + + /// + /// Once asked to stop, Windows stops, so the wait for it is not cancellable: the set never looks advertising when it + /// is not. An abort meanwhile, e.g. by the radio turning off, ends it as well + /// + private async Task StopPublisherAsync(BluetoothLEAdvertisementPublisher publisher) + { + publisher.StatusChanged -= OnRunningStatusChanged; + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnChanged( + BluetoothLEAdvertisementPublisher sender, + BluetoothLEAdvertisementPublisherStatusChangedEventArgs args + ) + { + if ( + args.Status + is BluetoothLEAdvertisementPublisherStatus.Stopped + or BluetoothLEAdvertisementPublisherStatus.Aborted + ) + stopped.TrySetResult(); + } + // Before the status is read, so an abort in between is not missed + publisher.StatusChanged += OnChanged; + try + { + if ( + publisher.Status + is not BluetoothLEAdvertisementPublisherStatus.Started + and not BluetoothLEAdvertisementPublisherStatus.Waiting + ) + return; + publisher.Stop(); + await stopped.Task.ConfigureAwait(false); + } + finally + { + publisher.StatusChanged -= OnChanged; + } + } + + public Task SetParametersAsync(AdvertisingParameters parameters, CancellationToken cancellationToken) + { + _parameters = parameters; + return Task.CompletedTask; + } + + /// + /// A running publisher takes no new data, so a new one starts before the old one stops: when starting fails, the + /// set keeps advertising its old data + /// + public async Task SetDataAsync(AdvertisingData data, CancellationToken cancellationToken) + { + if (_parameters.Type.HasFlag(BleEventType.Connectable) && data.Count > 0) + throw new NotSupportedException( + "Windows advertises connectable only the added services, without data of the app's own" + ); + AdvertisingData previous = _data; + _data = data; + // An aborted publisher was reported to the core as stopped; the data apply at the next start + if (_publisher is not { Status: not BluetoothLEAdvertisementPublisherStatus.Aborted } running) + return; + BluetoothLEAdvertisementPublisher started; + try + { + started = await StartPublisherAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + _data = previous; + throw; + } + Publish(started); + // The new data apply already; the old publisher only has to go + await StopPublisherAsync(running).ConfigureAwait(false); + } + + public Task SetScanResponseDataAsync(AdvertisingData data, CancellationToken cancellationToken) => + throw new NotSupportedException("Windows sends no scan response data of the app's own"); + + public Task SetRandomAddressAsync(BleAddress address, CancellationToken cancellationToken) => + throw new NotSupportedException("Windows chooses the advertising address itself"); + + public ValueTask DisposeAsync() => new(StopAsync(CancellationToken.None)); + + private BluetoothLEAdvertisementPublisher CreatePublisher() + { + var publisher = new BluetoothLEAdvertisementPublisher + { + UseExtendedAdvertisement = !_parameters.Type.HasFlag(BleEventType.Legacy), + }; + if (_parameters.AdvertisingTxPower is not TxPowerLevel.NotAvailable) + { + publisher.IncludeTransmitPowerLevel = true; + publisher.PreferredTransmitPowerLevelInDBm = (short)_parameters.AdvertisingTxPower; + } + (IReadOnlyList<(AdTypes Type, ReadOnlyMemory Bytes)> kept, IReadOnlyList dropped) = + PublisherSections.Split(_data); + foreach (AdTypes type in dropped) + _logger.LogReservedSectionDropped((byte)type); + foreach ((AdTypes type, ReadOnlyMemory bytes) in kept) + publisher.Advertisement.DataSections.Add( + new BluetoothLEAdvertisementDataSection((byte)type, bytes.ToArray().AsBuffer()) + ); + return publisher; + } + + /// Makes a started publisher the set's; an abort it ignored while it was not the set's yet is reported now + private void Publish(BluetoothLEAdvertisementPublisher publisher) + { + _publisher = publisher; + if (publisher.Status is BluetoothLEAdvertisementPublisherStatus.Aborted) + ReportAborted(publisher, null); + } + + /// Only a started publisher has this handler, and a stop the core asks for removes it first + private void OnRunningStatusChanged( + BluetoothLEAdvertisementPublisher sender, + BluetoothLEAdvertisementPublisherStatusChangedEventArgs args + ) + { + if (args.Status is BluetoothLEAdvertisementPublisherStatus.Aborted) + ReportAborted(sender, args.Error); + } + + /// A publisher this set replaced, or has not taken yet, reports nothing for the current one + private void ReportAborted(BluetoothLEAdvertisementPublisher publisher, BluetoothError? error) + { + if (!ReferenceEquals(publisher, _publisher)) + return; + publisher.StatusChanged -= OnRunningStatusChanged; + _sink.OnFailed(Stopped(error)); + } + + /// Without an error, Windows aborted the publisher before the set took it; Windows reports no reason then + private static BleException Stopped(BluetoothError? error) => + new( + error is null ? "Windows stopped advertising" : $"Windows stopped advertising: {error}", + isTransient: error is null or BluetoothError.RadioNotAvailable, + nativeCode: null, + innerException: null + ); + + /// Waits until the publisher reaches the status or Windows aborts it, and returns which, with Windows' error + private static async Task WaitForAsync( + BluetoothLEAdvertisementPublisher publisher, + BluetoothLEAdvertisementPublisherStatus status, + CancellationToken cancellationToken + ) + { + var reached = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously + ); + void OnChanged( + BluetoothLEAdvertisementPublisher sender, + BluetoothLEAdvertisementPublisherStatusChangedEventArgs args + ) + { + if (args.Status == status || args.Status is BluetoothLEAdvertisementPublisherStatus.Aborted) + reached.TrySetResult(args); + } + publisher.StatusChanged += OnChanged; + try + { + return await reached.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + publisher.StatusChanged -= OnChanged; + } + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Gap/WinRTBroadcasterDriver.cs b/src/Darp.Ble.WinRT/Windows/Gap/WinRTBroadcasterDriver.cs new file mode 100644 index 00000000..fd800db6 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gap/WinRTBroadcasterDriver.cs @@ -0,0 +1,35 @@ +using Darp.Ble.Backends; +using Darp.Ble.Gap; +using Darp.Ble.WinRT.Connections; +using Microsoft.Extensions.Logging; + +namespace Darp.Ble.WinRT.Gap; + +/// +/// Advertising on Windows. A non-connectable set is an advertisement publisher with the app's data; a connectable +/// set advertises the added services, as Windows connects centrals only to those. Windows chooses intervals and +/// addresses, sends no scan response data of the app's own, and keeps advertising when a central connects, so +/// sets are never stopped by a connection. When the radio turns off, a non-connectable set stops; connectable sets go on +/// once it is back, as Windows advertises the services again by itself +/// +internal sealed class WinRTBroadcasterDriver(WinRTPeripheralDriver? peripheral, ILogger logger) : IBroadcasterDriver +{ + private readonly WinRTPeripheralDriver? _peripheral = peripheral; + private readonly ILogger _logger = logger; + + public Task CreateAdvertisingSetAsync( + AdvertisingParameters parameters, + AdvertisingData data, + AdvertisingData? scanResponseData, + IAdvertisingSetSink sink, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + if (scanResponseData is not null) + throw new NotSupportedException("Windows sends no scan response data of the app's own"); + return Task.FromResult( + new WinRTAdvertisingSetDriver(_peripheral, parameters, data, sink, _logger) + ); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Gap/WinRTObserverDriver.cs b/src/Darp.Ble.WinRT/Windows/Gap/WinRTObserverDriver.cs new file mode 100644 index 00000000..8bcb4032 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gap/WinRTObserverDriver.cs @@ -0,0 +1,150 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.Gap; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.Advertisement; +using Windows.Devices.Radios; + +namespace Darp.Ble.WinRT.Gap; + +/// +/// One watcher per radio scan. Windows chooses scan interval and window itself; only the scan type applies. +/// Each scan's callbacks belong to its own watcher, so a stopped scan never reports into the next one. +/// When the radio turns off, Windows stops the watcher for good, so the scan fails +/// +internal sealed class WinRTObserverDriver(Radio radio, TimeProvider timeProvider) : IObserverDriver, IAsyncDisposable +{ + private readonly Radio _radio = radio; + private readonly TimeProvider _timeProvider = timeProvider; + private Run? _run; + + public Task StartScanAsync(BleScanParameters parameters, IScanSink sink, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WinRTRadio.ThrowIfOff(_radio); + var watcher = new BluetoothLEAdvertisementWatcher + { + ScanningMode = + parameters.ScanType is ScanType.Active + ? BluetoothLEScanningMode.Active + : BluetoothLEScanningMode.Passive, + AllowExtendedAdvertisements = true, + }; + var run = new Run(watcher, sink, _timeProvider); + _run = run; + // Both handlers before Start, so no report or stop of this run is missed + watcher.Received += run.OnReceived; + watcher.Stopped += run.OnStopped; + watcher.Start(); + if (watcher.Status is BluetoothLEAdvertisementWatcherStatus.Aborted) + { + run.Detach(); + _run = null; + throw new BleException("Windows could not start scanning; the Bluetooth adapter may need a restart"); + } + return Task.CompletedTask; + } + + public async Task StopScanAsync(CancellationToken cancellationToken) + { + Run? run = _run; + _run = null; + if (run is not null) + await run.StopAsync(cancellationToken).ConfigureAwait(false); + } + + public ValueTask DisposeAsync() => new(StopScanAsync(CancellationToken.None)); + + /// One radio scan with its watcher and the sink it reports to + private sealed class Run(BluetoothLEAdvertisementWatcher watcher, IScanSink sink, TimeProvider timeProvider) + { + private readonly TaskCompletionSource _stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + private volatile bool _detached; + + public void OnReceived(BluetoothLEAdvertisementWatcher sender, BluetoothLEAdvertisementReceivedEventArgs report) + { + if (_detached) + return; + sink.OnReport(ToAdvertisement(report, timeProvider.GetUtcNow())); + } + + public void OnStopped( + BluetoothLEAdvertisementWatcher sender, + BluetoothLEAdvertisementWatcherStoppedEventArgs args + ) + { + _stopped.TrySetResult(); + // A stop this driver asked for detached the run first; any other stop ends the scan + if (_detached) + return; + _detached = true; + sink.OnFailed( + args.Error is BluetoothError.Success + // Windows reports no error when the radio turns off + ? new BleException( + "Windows stopped scanning; the Bluetooth radio may be off", + isTransient: true, + nativeCode: null, + innerException: null + ) + : new BleException( + $"Windows stopped scanning: {args.Error}", + isTransient: args.Error is BluetoothError.RadioNotAvailable, + nativeCode: null, + innerException: null + ) + ); + } + + /// Stop only asks the watcher to stop; it has stopped once it reports so + public async Task StopAsync(CancellationToken cancellationToken) + { + _detached = true; + if ( + watcher.Status + is BluetoothLEAdvertisementWatcherStatus.Started + or BluetoothLEAdvertisementWatcherStatus.Stopping + ) + { + watcher.Stop(); + await _stopped.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + Detach(); + } + + public void Detach() + { + _detached = true; + watcher.Received -= OnReceived; + watcher.Stopped -= OnStopped; + } + } + + private static GapAdvertisement ToAdvertisement( + BluetoothLEAdvertisementReceivedEventArgs report, + DateTimeOffset timestamp + ) + { + (AdTypes Section, ReadOnlyMemory Bytes)[] sections = + [ + .. report.Advertisement.DataSections.Select(section => + ((AdTypes)section.DataType, (ReadOnlyMemory)section.Data.ToArray()) + ), + ]; + return GapAdvertisement.Create( + timestamp, + WinRTMapping.ToEventType(report), + WinRTMapping.ToAddress(report.BluetoothAddress, report.BluetoothAddressType), + Physical.NotAvailable, + Physical.NotAvailable, + AdvertisingSId.NoAdIProvided, + report.TransmitPowerLevelInDBm is { } power ? (TxPowerLevel)power : TxPowerLevel.NotAvailable, + (Rssi)report.RawSignalStrengthInDBm, + PeriodicAdvertisingInterval.NoPeriodicAdvertising, + BleAddress.NotAvailable, + AdvertisingData.From(sections) + ); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Gatt/WinRTHostedService.cs b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTHostedService.cs new file mode 100644 index 00000000..e4fa8a74 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTHostedService.cs @@ -0,0 +1,281 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.Gatt; +using Darp.Ble.WinRT.Connections; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; +using Windows.Foundation; + +namespace Darp.Ble.WinRT.Gatt; + +/// +/// One added service: Windows' service provider with the characteristics and descriptors of the definition. +/// Windows creates the CCCDs itself and takes a user description as characteristic metadata +/// +internal sealed class WinRTHostedService : IDisposable +{ + private const byte UnlikelyError = 0x0E; + private const byte InvalidOffset = 0x07; + private static readonly BleUuid UserDescription = BleUuid.FromUInt16(0x2901); + private static readonly BleUuid Cccd = BleUuid.FromUInt16(0x2902); + + private readonly WinRTPeripheralDriver _peripheral; + private readonly GattServiceProvider _provider; + private readonly Dictionary _characteristics = []; + private bool _advertising; + + private WinRTHostedService(WinRTPeripheralDriver peripheral, GattServiceProvider provider) + { + _peripheral = peripheral; + _provider = provider; + } + + /// Creates the service with every characteristic and descriptor; a failure removes what was created + public static async Task CreateAsync( + WinRTPeripheralDriver peripheral, + GattServiceDefinition definition, + CancellationToken cancellationToken + ) + { + GattServiceProviderResult result = await GattServiceProvider + .CreateAsync(definition.Uuid.Value) + .AsTask(cancellationToken) + .ConfigureAwait(false); + if (result.Error is not BluetoothError.Success) + throw new BleException( + $"Windows could not create the service {definition.Uuid}: {result.Error}", + isTransient: result.Error is BluetoothError.RadioNotAvailable, + nativeCode: null, + innerException: null + ); + var service = new WinRTHostedService(peripheral, result.ServiceProvider); + try + { + foreach (GattCharacteristicDefinition characteristic in definition.Characteristics) + await service.AddAsync(characteristic, cancellationToken).ConfigureAwait(false); + return service; + } + catch + { + service.Dispose(); + throw; + } + } + + /// The native characteristic of an added definition, or null + public GattLocalCharacteristic? Find(GattCharacteristicDefinition characteristic) => + _characteristics.GetValueOrDefault(characteristic); + + private async Task AddAsync(GattCharacteristicDefinition definition, CancellationToken cancellationToken) + { + var parameters = new GattLocalCharacteristicParameters + { + CharacteristicProperties = WinRTMapping.ToWindows(definition.Properties), + ReadProtectionLevel = GattProtectionLevel.Plain, + WriteProtectionLevel = GattProtectionLevel.Plain, + }; + GattDescriptorDefinition? description = definition.Descriptors.FirstOrDefault(d => d.Uuid == UserDescription); + if (description?.StaticValue is { } text) + parameters.UserDescription = Encoding.UTF8.GetString(text.Span); + GattLocalCharacteristicResult result = await _provider + .Service.CreateCharacteristicAsync(definition.Uuid.Value, parameters) + .AsTask(cancellationToken) + .ConfigureAwait(false); + if (result.Error is not BluetoothError.Success) + throw new BleException($"Windows could not create the characteristic {definition.Uuid}: {result.Error}"); + GattLocalCharacteristic characteristic = result.Characteristic; + _characteristics[definition] = characteristic; + characteristic.ReadRequested += (sender, args) => + _ = ReadAsync(args.Session, definition, args.GetDeferral(), args.GetRequestAsync()); + characteristic.WriteRequested += (sender, args) => + _ = WriteAsync(args.Session, definition, args.GetDeferral(), args.GetRequestAsync()); + characteristic.SubscribedClientsChanged += (sender, args) => + _ = _peripheral.OnSubscribedClientsChangedAsync(definition, sender); + foreach (GattDescriptorDefinition descriptor in definition.Descriptors) + { + // Windows generates both itself + if (descriptor.Uuid == Cccd || (descriptor.Uuid == UserDescription && descriptor.StaticValue is not null)) + continue; + GattLocalDescriptorResult created = await characteristic + .CreateDescriptorAsync( + descriptor.Uuid.Value, + new GattLocalDescriptorParameters + { + ReadProtectionLevel = GattProtectionLevel.Plain, + WriteProtectionLevel = GattProtectionLevel.Plain, + } + ) + .AsTask(cancellationToken) + .ConfigureAwait(false); + if (created.Error is not BluetoothError.Success) + throw new BleException($"Windows could not create the descriptor {descriptor.Uuid}: {created.Error}"); + created.Descriptor.ReadRequested += (sender, args) => + _ = ReadAsync(args.Session, descriptor, args.GetDeferral(), args.GetRequestAsync()); + created.Descriptor.WriteRequested += (sender, args) => + _ = WriteAsync(args.Session, descriptor, args.GetDeferral(), args.GetRequestAsync()); + } + } + + /// + /// Advertises the service; Windows serves a service to centrals only this way. Windows reports Aborted without an + /// error on the way to Started, also when it starts again after a stop + /// + public async Task StartAdvertisingAsync(CancellationToken cancellationToken) + { + if (_advertising) + return; + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnChanged(GattServiceProvider sender, GattServiceProviderAdvertisementStatusChangedEventArgs args) + { + if (IsStarted(args.Status)) + started.TrySetResult(); + else if ( + args.Status is GattServiceProviderAdvertisementStatus.Aborted + && args.Error is not BluetoothError.Success + ) + started.TrySetException(new BleException($"Windows could not add the service: {args.Error}")); + } + _provider.AdvertisementStatusChanged += OnChanged; + try + { + // After a stop the status still says Started, so only the events of this start count then + bool stale = IsStarted(_provider.AdvertisementStatus); + _provider.StartAdvertising( + new GattServiceProviderAdvertisingParameters { IsConnectable = true, IsDiscoverable = true } + ); + _advertising = true; + if (!stale && IsStarted(_provider.AdvertisementStatus)) + started.TrySetResult(); + await started.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + StopAdvertising(); + throw; + } + finally + { + _provider.AdvertisementStatusChanged -= OnChanged; + } + } + + /// Stops advertising the service; Windows does not report this in the provider's status + public void StopAdvertising() + { + if (!_advertising) + return; + _advertising = false; + _provider.StopAdvertising(); + } + + private static bool IsStarted(GattServiceProviderAdvertisementStatus status) => + status + is GattServiceProviderAdvertisementStatus.Started + or GattServiceProviderAdvertisementStatus.StartedWithoutAllAdvertisementData; + + private async Task ReadAsync( + GattSession session, + GattAttributeDefinition attribute, + Deferral deferral, + IAsyncOperation requested + ) + { + using (deferral) + { + try + { + WinRTCentralLink? central = await _peripheral.ResolveAsync(session).ConfigureAwait(false); + GattReadRequest? request = await requested.AsTask().ConfigureAwait(false); + // A session that closed, or a request Windows withdrew + if (central is null || request is null) + return; + byte[] value; + try + { + value = await _peripheral.Sink.OnReadRequest(central, attribute).ConfigureAwait(false); + } + catch (Exception e) + { + Fail(request.RespondWithProtocolError, e); + return; + } + if (request.Offset > (uint)value.Length) + { + request.RespondWithProtocolError(InvalidOffset); + return; + } + request.RespondWithValue(value.AsBuffer((int)request.Offset, value.Length - (int)request.Offset)); + } + catch (Exception e) + { + // An event handler must not throw into Windows; the central's request times out + _peripheral.Logger.LogHostedRequestFailed(e); + } + } + } + + private async Task WriteAsync( + GattSession session, + GattAttributeDefinition attribute, + Deferral deferral, + IAsyncOperation requested + ) + { + using (deferral) + { + try + { + WinRTCentralLink? central = await _peripheral.ResolveAsync(session).ConfigureAwait(false); + GattWriteRequest? request = await requested.AsTask().ConfigureAwait(false); + if (central is null || request is null) + return; + byte[] value = request.Value.ToArray(); + if (request.Option is GattWriteOption.WriteWithoutResponse) + { + _peripheral.Sink.OnWriteCommand(central, attribute, value); + return; + } + if (request.Offset != 0) + { + request.RespondWithProtocolError(InvalidOffset); + return; + } + try + { + await _peripheral.Sink.OnWriteRequest(central, attribute, value).ConfigureAwait(false); + } + catch (Exception e) + { + Fail(request.RespondWithProtocolError, e); + return; + } + request.Respond(); + } + catch (Exception e) + { + // An event handler must not throw into Windows; the central's request times out + _peripheral.Logger.LogHostedRequestFailed(e); + } + } + } + + /// The core's failure as an ATT error; a connection that ends gets no answer + private static void Fail(Action respondWithError, Exception exception) + { + switch (exception) + { + case OperationCanceledException: + return; + case GattException { AttStatus: { } status }: + respondWithError((byte)status); + return; + default: + respondWithError(UnlikelyError); + return; + } + } + + public void Dispose() => StopAdvertising(); +} diff --git a/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteCharacteristic.cs b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteCharacteristic.cs new file mode 100644 index 00000000..1983ca9c --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteCharacteristic.cs @@ -0,0 +1,101 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.Gatt; +using Darp.Ble.WinRT.Connections; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace Darp.Ble.WinRT.Gatt; + +internal sealed class WinRTRemoteCharacteristic : IRemoteCharacteristicDriver +{ + private readonly WinRTConnectionDriver _connection; + private readonly GattCharacteristic _characteristic; + + public WinRTRemoteCharacteristic(WinRTConnectionDriver connection, GattCharacteristic characteristic) + { + _connection = connection; + _characteristic = characteristic; + Uuid = BleUuid.FromGuid(characteristic.Uuid, inferType: true); + Properties = WinRTMapping.ToProperties(characteristic.CharacteristicProperties); + // Before any CCCD write, so a value the peer sends right after enabling is not lost + _characteristic.ValueChanged += OnValueChanged; + } + + public BleUuid Uuid { get; } + + public GattProperty Properties { get; } + + public async Task ReadAsync(GattRequest request) + { + GattReadResult result = await NativeCall + .RunAsync(request, () => _characteristic.ReadValueAsync(BluetoothCacheMode.Uncached)) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "read"); + return result.Value.ToArray(); + } + + public async Task WriteAsync(ReadOnlyMemory value, GattRequest request) + { + GattWriteResult result = await NativeCall + .RunAsync( + request, + () => + _characteristic.WriteValueWithResultAsync( + value.ToArray().AsBuffer(), + GattWriteOption.WriteWithResponse + ) + ) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "write"); + } + + /// + /// Windows has no signal for buffer space: the write is handed off at once, and only waiting in the core's queue + /// can be cancelled. The task completes with Windows' result; the bytes are copied before the call + /// + public async ValueTask WriteWithoutResponseAsync( + ReadOnlyMemory value, + CancellationToken cancellationToken, + CancellationToken connectionClosed + ) + { + cancellationToken.ThrowIfCancellationRequested(); + GattWriteResult result = await _characteristic + .WriteValueWithResultAsync(value.ToArray().AsBuffer(), GattWriteOption.WriteWithoutResponse) + .AsTask() + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "write without response"); + } + + public async Task> DiscoverDescriptorsAsync(GattRequest request) + { + GattDescriptorsResult result = await NativeCall + .RunAsync(request, () => _characteristic.GetDescriptorsAsync(BluetoothCacheMode.Uncached)) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "discover descriptors"); + return [.. result.Descriptors.Select(_connection.GetDescriptor)]; + } + + public async Task SetNotificationsAsync(bool enabled, GattRequest request) + { + GattWriteResult result = await NativeCall + .RunAsync( + request, + () => + _characteristic.WriteClientCharacteristicConfigurationDescriptorWithResultAsync( + enabled + ? GattClientCharacteristicConfigurationDescriptorValue.Notify + : GattClientCharacteristicConfigurationDescriptorValue.None + ) + ) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "write the CCCD"); + } + + private void OnValueChanged(GattCharacteristic sender, GattValueChangedEventArgs args) => + _connection.Sink.OnNotification(this, args.CharacteristicValue.ToArray()); + + public void Detach() => _characteristic.ValueChanged -= OnValueChanged; +} diff --git a/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteDescriptor.cs b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteDescriptor.cs new file mode 100644 index 00000000..c6ded68d --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteDescriptor.cs @@ -0,0 +1,34 @@ +using System.Runtime.InteropServices.WindowsRuntime; +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.WinRT.Connections; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace Darp.Ble.WinRT.Gatt; + +internal sealed class WinRTRemoteDescriptor(WinRTConnectionDriver connection, GattDescriptor descriptor) + : IRemoteDescriptorDriver +{ + private readonly WinRTConnectionDriver _connection = connection; + private readonly GattDescriptor _descriptor = descriptor; + + public BleUuid Uuid { get; } = BleUuid.FromGuid(descriptor.Uuid, inferType: true); + + public async Task ReadAsync(GattRequest request) + { + GattReadResult result = await NativeCall + .RunAsync(request, () => _descriptor.ReadValueAsync(BluetoothCacheMode.Uncached)) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "read the descriptor"); + return result.Value.ToArray(); + } + + public async Task WriteAsync(ReadOnlyMemory value, GattRequest request) + { + GattWriteResult result = await NativeCall + .RunAsync(request, () => _descriptor.WriteValueWithResultAsync(value.ToArray().AsBuffer())) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "write the descriptor"); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteService.cs b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteService.cs new file mode 100644 index 00000000..9cf067f3 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/Gatt/WinRTRemoteService.cs @@ -0,0 +1,31 @@ +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.WinRT.Connections; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace Darp.Ble.WinRT.Gatt; + +internal sealed class WinRTRemoteService(WinRTConnectionDriver connection, GattDeviceService service) + : IRemoteServiceDriver, + IDisposable +{ +#pragma warning disable CA2213 // The connection owns its services, not the other way round + private readonly WinRTConnectionDriver _connection = connection; +#pragma warning restore CA2213 + private readonly GattDeviceService _service = service; + + public BleUuid Uuid { get; } = BleUuid.FromGuid(service.Uuid, inferType: true); + + /// One Windows call; Windows reads the pages itself, so an abandoned request cannot stop between them + public async Task> DiscoverCharacteristicsAsync(GattRequest request) + { + GattCharacteristicsResult result = await NativeCall + .RunAsync(request, () => _service.GetCharacteristicsAsync(BluetoothCacheMode.Uncached)) + .ConfigureAwait(false); + _connection.ThrowIfFailed(result.Status, result.ProtocolError, "discover characteristics"); + return _connection.WrapCharacteristics(result.Characteristics); + } + + public void Dispose() => _service.Dispose(); +} diff --git a/src/Darp.Ble.WinRT/Windows/NativeCall.cs b/src/Darp.Ble.WinRT/Windows/NativeCall.cs new file mode 100644 index 00000000..e462dd95 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/NativeCall.cs @@ -0,0 +1,27 @@ +using Darp.Ble.Backends; +using Windows.Foundation; + +namespace Darp.Ble.WinRT; + +/// +/// One GATT request as a Windows call. Windows hides its own queue and the transactions inside a call, so the +/// request counts as sent once the call started, and the ATT timeout includes Windows' queueing. A started call is +/// never cancelled natively: its effect on a sent request is undocumented +/// +internal static class NativeCall +{ + public static async Task RunAsync(GattRequest request, Func> start) + { + request.HandOffToken.ThrowIfCancellationRequested(); + IAsyncOperation operation = start(); + request.OnTransactionSent(); + try + { + return await operation.AsTask().ConfigureAwait(false); + } + finally + { + request.OnTransactionAnswered(); + } + } +} diff --git a/src/Darp.Ble.WinRT/Windows/WinRTAdapterDriver.cs b/src/Darp.Ble.WinRT/Windows/WinRTAdapterDriver.cs new file mode 100644 index 00000000..c9601db8 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/WinRTAdapterDriver.cs @@ -0,0 +1,55 @@ +using Darp.Ble.Backends; +using Darp.Ble.Data; +using Darp.Ble.WinRT.Connections; +using Darp.Ble.WinRT.Gap; +using Microsoft.Extensions.Logging; +using Windows.Devices.Bluetooth; +using Windows.Devices.Radios; + +namespace Darp.Ble.WinRT; + +/// The opened default adapter; its roles follow what Windows says the adapter can do +internal sealed class WinRTAdapterDriver : IBleAdapterDriver +{ + private readonly WinRTObserverDriver _observer; + private readonly WinRTCentralDriver? _central; + private readonly WinRTPeripheralDriver? _peripheral; + private readonly WinRTBroadcasterDriver? _broadcaster; + + public WinRTAdapterDriver(BluetoothAdapter adapter, Radio radio, BleBackendContext context) + { + _observer = new WinRTObserverDriver(radio, context.TimeProvider); + RandomAddress = new BleAddress(BleAddressType.Public, (UInt48)adapter.BluetoothAddress); + if (adapter.IsCentralRoleSupported) + _central = new WinRTCentralDriver(); + ILogger logger = context.LoggerFactory.CreateLogger(); + if (adapter.IsPeripheralRoleSupported) + _peripheral = new WinRTPeripheralDriver(radio, logger); + // The peripheral role includes the broadcaster; without it, advertising needs offloading + if (adapter.IsPeripheralRoleSupported || adapter.IsAdvertisementOffloadSupported) + _broadcaster = new WinRTBroadcasterDriver(_peripheral, logger); + } + + /// Windows sets the address and serves the GAP service itself + public Capabilities Features => Capabilities.None; + + public IObserverDriver? Observer => _observer; + + public IBroadcasterDriver? Broadcaster => _broadcaster; + + public ICentralDriver? Central => _central; + + public IPeripheralDriver? Peripheral => _peripheral; + + /// The adapter's own address; Windows chooses the addresses it advertises and connects with + public BleAddress RandomAddress { get; } + + public Task SetRandomAddressAsync(BleAddress address, CancellationToken cancellationToken) => + throw new NotSupportedException("Windows chooses the adapter's addresses itself"); + + public async ValueTask DisposeAsync() + { + await _observer.DisposeAsync().ConfigureAwait(false); + _peripheral?.Dispose(); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/WinRTAdapterSource.cs b/src/Darp.Ble.WinRT/Windows/WinRTAdapterSource.cs new file mode 100644 index 00000000..d6412dab --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/WinRTAdapterSource.cs @@ -0,0 +1,36 @@ +using Darp.Ble.Backends; +using Windows.Devices.Bluetooth; +using Windows.Devices.Radios; + +namespace Darp.Ble.WinRT; + +/// The system's default Bluetooth adapter +internal sealed class WinRTAdapterSource : IBleAdapterSource +{ + public string? Name => "Windows"; + + public async Task OpenAsync(BleBackendContext context, CancellationToken cancellationToken) + { + BluetoothAdapter? adapter = await BluetoothAdapter + .GetDefaultAsync() + .AsTask(cancellationToken) + .ConfigureAwait(false); + if (adapter is null) + throw new BleOpenException(BleOpenErrorKind.NotAvailable, "Windows has no Bluetooth adapter"); + if (!adapter.IsLowEnergySupported) + throw new BleOpenException( + BleOpenErrorKind.NotAvailable, + "The Bluetooth adapter does not support Low Energy" + ); + Radio? radio = await adapter.GetRadioAsync().AsTask(cancellationToken).ConfigureAwait(false); + if (radio is not { State: RadioState.On }) + { + throw new BleOpenException( + BleOpenErrorKind.Disabled, + $"The Bluetooth radio is {radio?.State.ToString() ?? "unavailable"}", + isTransient: true + ); + } + return new WinRTAdapterDriver(adapter, radio, context); + } +} diff --git a/src/Darp.Ble.WinRT/Windows/WinRTMapping.cs b/src/Darp.Ble.WinRT/Windows/WinRTMapping.cs new file mode 100644 index 00000000..89b3f756 --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/WinRTMapping.cs @@ -0,0 +1,55 @@ +using Darp.Ble.Data; +using Darp.Ble.Gap; +using Darp.Ble.Gatt; +using Windows.Devices.Bluetooth; +using Windows.Devices.Bluetooth.Advertisement; +using Windows.Devices.Bluetooth.GenericAttributeProfile; + +namespace Darp.Ble.WinRT; + +/// Conversions between Windows' Bluetooth types and the library's +internal static class WinRTMapping +{ + public static BleAddress ToAddress(ulong address, BluetoothAddressType type) => + new( + type switch + { + BluetoothAddressType.Public => BleAddressType.Public, + BluetoothAddressType.Random => BleAddressType.Random, + _ => BleAddressType.NotAvailable, + }, + (UInt48)address + ); + + public static BluetoothAddressType ToWindows(BleAddressType type) => + type switch + { + BleAddressType.Public or BleAddressType.PublicIdentity => BluetoothAddressType.Public, + BleAddressType.NotAvailable => BluetoothAddressType.Unspecified, + _ => BluetoothAddressType.Random, + }; + + public static BleEventType ToEventType(BluetoothLEAdvertisementReceivedEventArgs report) => + report.AdvertisementType switch + { + BluetoothLEAdvertisementType.ConnectableUndirected => BleEventType.AdvInd, + BluetoothLEAdvertisementType.ConnectableDirected => BleEventType.AdvDirectInd, + BluetoothLEAdvertisementType.ScannableUndirected => BleEventType.AdvScanInd, + BluetoothLEAdvertisementType.NonConnectableUndirected => BleEventType.AdvNonConnInd, + // A legacy scan response answers ADV_IND (connectable) or ADV_SCAN_IND, both scannable + BluetoothLEAdvertisementType.ScanResponse => BleEventType.Legacy + | BleEventType.Scannable + | BleEventType.ScanResponse + | (report.IsConnectable ? BleEventType.Connectable : BleEventType.None), + _ => (report.IsConnectable ? BleEventType.Connectable : BleEventType.None) + | (report.IsScannable ? BleEventType.Scannable : BleEventType.None) + | (report.IsDirected ? BleEventType.Directed : BleEventType.None) + | (report.IsScanResponse ? BleEventType.ScanResponse : BleEventType.None), + }; + + public static GattCharacteristicProperties ToWindows(GattProperty properties) => + (GattCharacteristicProperties)(uint)properties; + + public static GattProperty ToProperties(GattCharacteristicProperties properties) => + (GattProperty)((uint)properties & 0xFF); +} diff --git a/src/Darp.Ble.WinRT/Windows/WinRTRadio.cs b/src/Darp.Ble.WinRT/Windows/WinRTRadio.cs new file mode 100644 index 00000000..c7951c5d --- /dev/null +++ b/src/Darp.Ble.WinRT/Windows/WinRTRadio.cs @@ -0,0 +1,21 @@ +using Windows.Devices.Radios; + +namespace Darp.Ble.WinRT; + +/// +/// Windows' Bluetooth radio. A scan or a service started while the radio is off waits silently until it is on, so the +/// backend refuses to start one then +/// +internal static class WinRTRadio +{ + public static void ThrowIfOff(Radio radio) + { + if (radio.State is not RadioState.On) + throw new BleException( + $"The Bluetooth radio is {radio.State}", + isTransient: true, + nativeCode: null, + innerException: null + ); + } +} diff --git a/src/Darp.Ble/Backends/BleBackendContext.cs b/src/Darp.Ble/Backends/BleBackendContext.cs index 128de594..d2a70a45 100644 --- a/src/Darp.Ble/Backends/BleBackendContext.cs +++ b/src/Darp.Ble/Backends/BleBackendContext.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Diagnostics; using Microsoft.Extensions.Logging; namespace Darp.Ble.Backends; /// The services a gives to its backends and their drivers +[Experimental(Experiments.Backends)] public sealed class BleBackendContext { internal BleBackendContext(ILoggerFactory loggerFactory, TimeProvider timeProvider, DiagnosticsSettings diagnostics) diff --git a/src/Darp.Ble/Backends/Connections/ICentralConnectionDriver.cs b/src/Darp.Ble/Backends/Connections/ICentralConnectionDriver.cs index c4d911ef..7d588368 100644 --- a/src/Darp.Ble/Backends/Connections/ICentralConnectionDriver.cs +++ b/src/Darp.Ble/Backends/Connections/ICentralConnectionDriver.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// A central connected to the adapter's peripheral role. Belongs to its peripheral driver, which reports it through +[Experimental(Experiments.Backends)] public interface ICentralConnectionDriver { /// The address of the central diff --git a/src/Darp.Ble/Backends/Connections/ICentralDriver.cs b/src/Darp.Ble/Backends/Connections/ICentralDriver.cs index c2179890..b100a61b 100644 --- a/src/Darp.Ble/Backends/Connections/ICentralDriver.cs +++ b/src/Darp.Ble/Backends/Connections/ICentralDriver.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Connections; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// The central role of an adapter: connects to peripherals +[Experimental(Experiments.Backends)] public interface ICentralDriver { /// Connects to a peripheral diff --git a/src/Darp.Ble/Backends/Connections/IConnectionDriver.cs b/src/Darp.Ble/Backends/Connections/IConnectionDriver.cs index 1e285fe3..9a74d46a 100644 --- a/src/Darp.Ble/Backends/Connections/IConnectionDriver.cs +++ b/src/Darp.Ble/Backends/Connections/IConnectionDriver.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Connections; using Darp.Ble.Gatt; @@ -16,6 +17,7 @@ namespace Darp.Ble.Backends; /// /// Disposing it ends this app's connection and must end every task still running /// +[Experimental(Experiments.Backends)] public interface IConnectionDriver : IAsyncDisposable { /// Agrees on the ATT MTU with the peer diff --git a/src/Darp.Ble/Backends/Connections/IConnectionSink.cs b/src/Darp.Ble/Backends/Connections/IConnectionSink.cs index 7ba5e9c5..be3e66a5 100644 --- a/src/Darp.Ble/Backends/Connections/IConnectionSink.cs +++ b/src/Darp.Ble/Backends/Connections/IConnectionSink.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Connections; namespace Darp.Ble.Backends; /// Receives the events of one connection. Calls must stay cheap; the core does its work elsewhere +[Experimental(Experiments.Backends)] public interface IConnectionSink { /// Reports that the link ended without this app asking for it @@ -13,4 +15,8 @@ public interface IConnectionSink /// The characteristic, as the connection returned it from discovery /// The value; only valid during the call, the core copies it void OnNotification(IRemoteCharacteristicDriver characteristic, ReadOnlySpan value); + + /// The MTU grew after the exchange, e.g. Windows exchanges it itself once the link is up + /// The new MTU; a smaller one than the connection has is ignored + void OnMtuChanged(int mtu); } diff --git a/src/Darp.Ble/Backends/Connections/IPeripheralDriver.cs b/src/Darp.Ble/Backends/Connections/IPeripheralDriver.cs index 3ad7c102..a640c9ff 100644 --- a/src/Darp.Ble/Backends/Connections/IPeripheralDriver.cs +++ b/src/Darp.Ble/Backends/Connections/IPeripheralDriver.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data.AssignedNumbers; namespace Darp.Ble.Backends; /// The peripheral role of an adapter: accepts connections from centrals +[Experimental(Experiments.Backends)] public interface IPeripheralDriver { /// Binds the sink that receives connections. The core calls it once, when the adapter opens, before anything else diff --git a/src/Darp.Ble/Backends/Experiments.cs b/src/Darp.Ble/Backends/Experiments.cs new file mode 100644 index 00000000..3f3b3ac9 --- /dev/null +++ b/src/Darp.Ble/Backends/Experiments.cs @@ -0,0 +1,8 @@ +namespace Darp.Ble.Backends; + +/// Diagnostic ids of the API that may change in a minor release +internal static class Experiments +{ + /// The driver layer, for backends: written for this library's backends and changed with them + public const string Backends = "DARPBLE0001"; +} diff --git a/src/Darp.Ble/Backends/Gap/IAdvertisingSetDriver.cs b/src/Darp.Ble/Backends/Gap/IAdvertisingSetDriver.cs index 4999c68c..af759e10 100644 --- a/src/Darp.Ble/Backends/Gap/IAdvertisingSetDriver.cs +++ b/src/Darp.Ble/Backends/Gap/IAdvertisingSetDriver.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; using Darp.Ble.Gap; namespace Darp.Ble.Backends; /// One advertising set. The core serializes the calls of a set and only changes parameters while it is stopped +[Experimental(Experiments.Backends)] public interface IAdvertisingSetDriver : IAsyncDisposable { /// The address the set advertises with diff --git a/src/Darp.Ble/Backends/Gap/IAdvertisingSetSink.cs b/src/Darp.Ble/Backends/Gap/IAdvertisingSetSink.cs index 4edecbbb..a9d1d6ba 100644 --- a/src/Darp.Ble/Backends/Gap/IAdvertisingSetSink.cs +++ b/src/Darp.Ble/Backends/Gap/IAdvertisingSetSink.cs @@ -1,10 +1,18 @@ +using System.Diagnostics.CodeAnalysis; + namespace Darp.Ble.Backends; /// Receives what happens to an advertising set without the core asking. Called on the driver's thread; the core returns at once +[Experimental(Experiments.Backends)] public interface IAdvertisingSetSink { /// A central connected through the set, so the set stopped advertising /// Reported for the run the set advertises in, before a later or completes /// The connection, as reported to the peripheral role's before this call void OnStoppedByConnection(ICentralConnectionDriver central); + + /// The backend stopped the set because of an error, e.g. the radio turned off; it stays stopped until it is started again + /// Reported for the run the set advertises in, before a later or completes + /// Why the set stopped + void OnFailed(BleException error); } diff --git a/src/Darp.Ble/Backends/Gap/IBroadcasterDriver.cs b/src/Darp.Ble/Backends/Gap/IBroadcasterDriver.cs index 9259200d..24d04d29 100644 --- a/src/Darp.Ble/Backends/Gap/IBroadcasterDriver.cs +++ b/src/Darp.Ble/Backends/Gap/IBroadcasterDriver.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Gap; namespace Darp.Ble.Backends; /// The advertising of an adapter +[Experimental(Experiments.Backends)] public interface IBroadcasterDriver { /// Creates a stopped advertising set diff --git a/src/Darp.Ble/Backends/Gap/IObserverDriver.cs b/src/Darp.Ble/Backends/Gap/IObserverDriver.cs index e2791f59..422be67f 100644 --- a/src/Darp.Ble/Backends/Gap/IObserverDriver.cs +++ b/src/Darp.Ble/Backends/Gap/IObserverDriver.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Gap; namespace Darp.Ble.Backends; /// The scanning of an adapter. The core runs at most one radio scan at a time and shares it between all scans of the app +[Experimental(Experiments.Backends)] public interface IObserverDriver { /// Starts scanning and reports every advertisement to the sink until stopped diff --git a/src/Darp.Ble/Backends/Gap/IScanSink.cs b/src/Darp.Ble/Backends/Gap/IScanSink.cs index 584f29b3..fab007fb 100644 --- a/src/Darp.Ble/Backends/Gap/IScanSink.cs +++ b/src/Darp.Ble/Backends/Gap/IScanSink.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Gap; namespace Darp.Ble.Backends; /// Receives the reports of one radio scan. The core keeps every call cheap: it never blocks and never runs app code +[Experimental(Experiments.Backends)] public interface IScanSink { /// Reports an advertisement; may be called on the driver's read loop diff --git a/src/Darp.Ble/Backends/Gatt/GattAttributeDefinition.cs b/src/Darp.Ble/Backends/Gatt/GattAttributeDefinition.cs index f6f56a70..6ee10ab0 100644 --- a/src/Darp.Ble/Backends/Gatt/GattAttributeDefinition.cs +++ b/src/Darp.Ble/Backends/Gatt/GattAttributeDefinition.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// A hosted attribute centrals read or write: a characteristic or a descriptor. The object itself identifies it +[Experimental(Experiments.Backends)] public abstract class GattAttributeDefinition { private protected GattAttributeDefinition(BleUuid uuid) diff --git a/src/Darp.Ble/Backends/Gatt/GattCharacteristicDefinition.cs b/src/Darp.Ble/Backends/Gatt/GattCharacteristicDefinition.cs index e5630595..1da40d92 100644 --- a/src/Darp.Ble/Backends/Gatt/GattCharacteristicDefinition.cs +++ b/src/Darp.Ble/Backends/Gatt/GattCharacteristicDefinition.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; using Darp.Ble.Gatt; @@ -5,6 +6,7 @@ namespace Darp.Ble.Backends; /// A characteristic of an added service. The object itself identifies it, also when two have the same UUID /// The backend adds the client characteristic configuration descriptor of a notifying characteristic itself +[Experimental(Experiments.Backends)] public sealed class GattCharacteristicDefinition : GattAttributeDefinition { internal GattCharacteristicDefinition(BleUuid uuid, GattProperty properties) diff --git a/src/Darp.Ble/Backends/Gatt/GattDescriptorDefinition.cs b/src/Darp.Ble/Backends/Gatt/GattDescriptorDefinition.cs index 230e6c1d..ccdb2320 100644 --- a/src/Darp.Ble/Backends/Gatt/GattDescriptorDefinition.cs +++ b/src/Darp.Ble/Backends/Gatt/GattDescriptorDefinition.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// A descriptor of an added characteristic. The object itself identifies it +[Experimental(Experiments.Backends)] public sealed class GattDescriptorDefinition : GattAttributeDefinition { internal GattDescriptorDefinition(BleUuid uuid, ReadOnlyMemory? staticValue) diff --git a/src/Darp.Ble/Backends/Gatt/GattRequest.cs b/src/Darp.Ble/Backends/Gatt/GattRequest.cs index c42f6220..b47cdd82 100644 --- a/src/Darp.Ble/Backends/Gatt/GattRequest.cs +++ b/src/Darp.Ble/Backends/Gatt/GattRequest.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Connections; namespace Darp.Ble.Backends; @@ -7,6 +8,7 @@ namespace Darp.Ble.Backends; /// A request takes one or more ATT transactions, e.g. discovery page by page, and reports each one, so the core can time out /// a peer that stops answering. A write without response has no transaction and gets its tokens directly /// +[Experimental(Experiments.Backends)] public sealed class GattRequest { private readonly RequestQueue _queue; diff --git a/src/Darp.Ble/Backends/Gatt/GattServiceDefinition.cs b/src/Darp.Ble/Backends/Gatt/GattServiceDefinition.cs index df940caa..e1b92aaa 100644 --- a/src/Darp.Ble/Backends/Gatt/GattServiceDefinition.cs +++ b/src/Darp.Ble/Backends/Gatt/GattServiceDefinition.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// A complete service to add; it does not change afterwards +[Experimental(Experiments.Backends)] public sealed class GattServiceDefinition { internal GattServiceDefinition( diff --git a/src/Darp.Ble/Backends/Gatt/IGattServerSink.cs b/src/Darp.Ble/Backends/Gatt/IGattServerSink.cs index 891e51e7..25aa8f30 100644 --- a/src/Darp.Ble/Backends/Gatt/IGattServerSink.cs +++ b/src/Darp.Ble/Backends/Gatt/IGattServerSink.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Connections; using Darp.Ble.Gatt; namespace Darp.Ble.Backends; /// Receives what centrals do with the adapter's peripheral role. Called on the driver's thread; the core returns at once +[Experimental(Experiments.Backends)] public interface IGattServerSink { /// A central connected diff --git a/src/Darp.Ble/Backends/Gatt/IRemoteCharacteristicDriver.cs b/src/Darp.Ble/Backends/Gatt/IRemoteCharacteristicDriver.cs index dc03ebd3..3c4d0d3b 100644 --- a/src/Darp.Ble/Backends/Gatt/IRemoteCharacteristicDriver.cs +++ b/src/Darp.Ble/Backends/Gatt/IRemoteCharacteristicDriver.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; using Darp.Ble.Gatt; @@ -5,6 +6,7 @@ namespace Darp.Ble.Backends; /// A characteristic discovered on a peripheral. Belongs to its connection, which returns the same object for the same characteristic every time /// Its operations fail as describes +[Experimental(Experiments.Backends)] public interface IRemoteCharacteristicDriver { /// The UUID of the characteristic diff --git a/src/Darp.Ble/Backends/Gatt/IRemoteDescriptorDriver.cs b/src/Darp.Ble/Backends/Gatt/IRemoteDescriptorDriver.cs index ede3d846..280508a5 100644 --- a/src/Darp.Ble/Backends/Gatt/IRemoteDescriptorDriver.cs +++ b/src/Darp.Ble/Backends/Gatt/IRemoteDescriptorDriver.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; using Darp.Ble.Gatt; @@ -5,6 +6,7 @@ namespace Darp.Ble.Backends; /// A descriptor discovered on a peripheral. Belongs to its connection /// Its operations fail as describes +[Experimental(Experiments.Backends)] public interface IRemoteDescriptorDriver { /// The UUID of the descriptor diff --git a/src/Darp.Ble/Backends/Gatt/IRemoteServiceDriver.cs b/src/Darp.Ble/Backends/Gatt/IRemoteServiceDriver.cs index 43bedba3..e228f744 100644 --- a/src/Darp.Ble/Backends/Gatt/IRemoteServiceDriver.cs +++ b/src/Darp.Ble/Backends/Gatt/IRemoteServiceDriver.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// A service discovered on a peripheral. Belongs to its connection; the core queues its requests with the connection's /// Its operations fail as describes +[Experimental(Experiments.Backends)] public interface IRemoteServiceDriver { /// The UUID of the service diff --git a/src/Darp.Ble/Backends/IBleAdapterDriver.cs b/src/Darp.Ble/Backends/IBleAdapterDriver.cs index 594ef14d..3a0fe4f2 100644 --- a/src/Darp.Ble/Backends/IBleAdapterDriver.cs +++ b/src/Darp.Ble/Backends/IBleAdapterDriver.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Data; namespace Darp.Ble.Backends; /// The driver of an opened adapter. The core wraps it in a and disposes it once +[Experimental(Experiments.Backends)] public interface IBleAdapterDriver : IAsyncDisposable { /// The features beyond the roles, e.g. ; the core adds the roles of the role drivers diff --git a/src/Darp.Ble/Backends/IBleAdapterSource.cs b/src/Darp.Ble/Backends/IBleAdapterSource.cs index fd072b51..6f1d8c28 100644 --- a/src/Darp.Ble/Backends/IBleAdapterSource.cs +++ b/src/Darp.Ble/Backends/IBleAdapterSource.cs @@ -1,6 +1,9 @@ +using System.Diagnostics.CodeAnalysis; + namespace Darp.Ble.Backends; /// One adapter a backend enumerated; the core shows it to apps as +[Experimental(Experiments.Backends)] public interface IBleAdapterSource { /// The name of the adapter, if the backend knows one diff --git a/src/Darp.Ble/Backends/IBleBackend.cs b/src/Darp.Ble/Backends/IBleBackend.cs index 9dabed55..22efdf0d 100644 --- a/src/Darp.Ble/Backends/IBleBackend.cs +++ b/src/Darp.Ble/Backends/IBleBackend.cs @@ -1,7 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + namespace Darp.Ble.Backends; /// A backend that connects the library to a platform or a radio, e.g. the mock, HCI or WinRT backend /// Implemented only by the backends of this library; apps register backends through their builder extensions +[Experimental(Experiments.Backends)] public interface IBleBackend { /// The name apps see as , e.g. diff --git a/src/Darp.Ble/BleBackends.cs b/src/Darp.Ble/BleBackends.cs index 2addf4f5..df546107 100644 --- a/src/Darp.Ble/BleBackends.cs +++ b/src/Darp.Ble/BleBackends.cs @@ -8,4 +8,7 @@ public static class BleBackends /// The HCI backend from the Darp.Ble.HciHost package, which drives a controller over a serial port public const string HciHost = "Darp.Ble.HciHost"; + + /// The Windows backend from the Darp.Ble.WinRT package, on Windows' own Bluetooth stack + public const string WinRT = "Darp.Ble.WinRT"; } diff --git a/src/Darp.Ble/BleManagerBuilder.cs b/src/Darp.Ble/BleManagerBuilder.cs index 20b2ace6..27725a85 100644 --- a/src/Darp.Ble/BleManagerBuilder.cs +++ b/src/Darp.Ble/BleManagerBuilder.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Darp.Ble.Backends; using Darp.Ble.Diagnostics; @@ -15,6 +16,7 @@ internal BleManagerBuilder() { } /// Adds a backend. Backend packages call this from their extensions, e.g. AddMock() /// The backend to add; adapters are enumerated in the order backends were added /// This builder + [Experimental(Experiments.Backends)] public BleManagerBuilder AddBackend(IBleBackend backend) { ArgumentNullException.ThrowIfNull(backend); diff --git a/src/Darp.Ble/Connections/BleCentral.cs b/src/Darp.Ble/Connections/BleCentral.cs index 7cba69a3..0d7777c2 100644 --- a/src/Darp.Ble/Connections/BleCentral.cs +++ b/src/Darp.Ble/Connections/BleCentral.cs @@ -68,7 +68,7 @@ public IReadOnlyList Connections /// so pass one that times out. The Mock fails at once for an address nobody advertises connectable. /// The adapter's scans pause while the link comes up and continue afterwards /// - /// The address of the peripheral + /// The address of the peripheral; without its type (, e.g. parsed from text) the backend uses the type the device was heard with, so scan for it first /// How the connection runs; the defaults when null /// Cancels connecting; the only limit on how long it takes /// The connection once the link is up. The caller owns it; disposing the adapter ends it too diff --git a/src/Darp.Ble/Connections/ConnectedPeripheral.cs b/src/Darp.Ble/Connections/ConnectedPeripheral.cs index 79514f72..75cd9e81 100644 --- a/src/Darp.Ble/Connections/ConnectedPeripheral.cs +++ b/src/Darp.Ble/Connections/ConnectedPeripheral.cs @@ -118,7 +118,7 @@ public async Task DiscoverServiceAsync( ?? throw new GattException(GattErrorKind.NotFound, $"The peripheral has no service {uuid}"); } - /// The longest value a write can carry, from the MTU agreed when connecting + /// The longest value a write can carry, from the MTU agreed when connecting; it may grow later, e.g. on Windows /// Longer writes are rejected on every backend; a notification longer than this is cut to it public int MaxWritePayloadLength => _maxWritePayloadLength; @@ -272,7 +272,17 @@ internal async Task ExchangeMtuAsync(CancellationToken cancellationToken) cancellationToken ) .ConfigureAwait(false); - _maxWritePayloadLength = mtu - WriteHeaderLength; + GrowMtu(mtu); + } + + /// The backend reports a larger MTU than the exchange returned + internal void OnMtuChanged(int mtu) => GrowMtu(mtu); + + /// Keeps the larger MTU: an MTU never shrinks, and the exchange and a change may arrive in either order + private void GrowMtu(int mtu) + { + lock (_lock) + _maxWritePayloadLength = Math.Max(_maxWritePayloadLength, mtu - WriteHeaderLength); } /// Ends the connection exactly once; the first reason wins diff --git a/src/Darp.Ble/Connections/ConnectionEvents.cs b/src/Darp.Ble/Connections/ConnectionEvents.cs index f16a8d52..512847f4 100644 --- a/src/Darp.Ble/Connections/ConnectionEvents.cs +++ b/src/Darp.Ble/Connections/ConnectionEvents.cs @@ -30,6 +30,15 @@ public void OnNotification(IRemoteCharacteristicDriver characteristic, ReadOnlyS connection?.OnNotification(characteristic, value); } + /// A change before the connection object exists is part of the exchange that follows + public void OnMtuChanged(int mtu) + { + ConnectedPeripheral? connection; + lock (_lock) + connection = _connection; + connection?.OnMtuChanged(mtu); + } + public void Attach(ConnectedPeripheral connection) { DisconnectInfo? earlyDisconnect; diff --git a/src/Darp.Ble/Data/BleAddress.cs b/src/Darp.Ble/Data/BleAddress.cs index 8bb41761..36ea7371 100644 --- a/src/Darp.Ble/Data/BleAddress.cs +++ b/src/Darp.Ble/Data/BleAddress.cs @@ -5,7 +5,7 @@ namespace Darp.Ble.Data; /// The ble address -public sealed record BleAddress : ISpanParsable, IEquatable, IEquatable +public sealed record BleAddress : ISpanParsable, IFormattable, IEquatable, IEquatable { /// Initializes a new ble address /// The 48bit address @@ -71,22 +71,20 @@ public static implicit operator UInt48(BleAddress bleAddress) )] public static explicit operator BleAddress(UInt48 value) => new(value); - /// Parses a string of suitable format and returns a ble address - /// The string to parse. - /// An object that provides culture-specific formatting information about . Will be ignored - /// AA:BB:CC:DD:EE:FF - /// The parsed BleAddress with type + /// Parses an address as writes it + /// The address: AA:BB:CC:DD:EE:FF, or with its type as P:AA:BB:CC:DD:EE:FF (public) or R:AA:BB:CC:DD:EE:FF (random) + /// Ignored + /// The address; without a type in the text, of type /// Thrown if the format does not comply. public static BleAddress Parse(ReadOnlySpan s, IFormatProvider? provider) => TryParse(s, provider, out BleAddress? address) ? address : throw new FormatException($"Given input '{s}' could not be parsed"); - /// Parses a string of suitable format and returns a ble address - /// The string to parse. - /// An object that provides culture-specific formatting information about . Will be ignored - /// The parsed BleAddress with type or default - /// AA:BB:CC:DD:EE:FF + /// Parses an address as writes it + /// The address: AA:BB:CC:DD:EE:FF, or with its type as P:AA:BB:CC:DD:EE:FF (public) or R:AA:BB:CC:DD:EE:FF (random) + /// Ignored + /// The address; without a type in the text, of type /// True if the parsing was successful public static bool TryParse( ReadOnlySpan s, @@ -94,63 +92,42 @@ public static bool TryParse( [NotNullWhen(true)] out BleAddress? result ) { - if (s.Length < 17) + result = null; + BleAddressType type = BleAddressType.NotAvailable; + if (s.Length == 19 && s[1] == ':') { - result = null; - return false; - } - if (s[2] != ':' || s[5] != ':' || s[8] != ':' || s[11] != ':' || s[14] != ':') - { - result = null; - return false; + type = s[0] switch + { + 'P' => BleAddressType.Public, + 'R' => BleAddressType.Random, + _ => BleAddressType.NotAvailable, + }; + if (type is BleAddressType.NotAvailable) + return false; + s = s[2..]; } - if ( - !TryGetHexVal(s, out byte b0) - || !TryGetHexVal(s[3..], out byte b1) - || !TryGetHexVal(s[6..], out byte b2) - || !TryGetHexVal(s[9..], out byte b3) - || !TryGetHexVal(s[12..], out byte b4) - || !TryGetHexVal(s[15..], out byte b5) - ) - { - result = null; + if (s.Length != 17) return false; - } - var value = new UInt48(b5, b4, b3, b2, b1, b0); - result = new BleAddress(BleAddressType.NotAvailable, value); - return true; - } - - private static bool TryGetHexVal(in ReadOnlySpan s, out byte res) - { - // The calling method already checked that the span is at least 2 characters long - int lower = GetHexVal(s[1]); - int upper = GetHexVal(s[0]); - if (lower > 15 || upper > 15) + ulong value = 0; + for (var i = 0; i < 6; i++) { - res = default; - return false; + if (i > 0 && s[(3 * i) - 1] != ':') + return false; + if ( + !byte.TryParse( + s.Slice(3 * i, 2), + NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, + out byte b + ) + ) + return false; + value = (value << 8) | b; } - res = (byte)((upper << 4) + lower); + result = new BleAddress(type, (UInt48)value); return true; } - private static int GetHexVal(char hex) - { - int val = hex; - //For uppercase A-F letters: - //return val - (val < 58 ? 48 : 55); - //For lowercase a-f letters: - //return val - (val < 58 ? 48 : 87); - //Or the two combined, but a bit slower: - return val - - ( - val < 58 ? 48 - : val < 97 ? 55 - : 87 - ); - } - /// public static BleAddress Parse(string s, IFormatProvider? provider) => Parse((ReadOnlySpan)s, provider); @@ -171,11 +148,30 @@ public static bool TryParse( public static BleAddress NotAvailable { get; } = new(BleAddressType.NotAvailable, UInt48.Zero); /// The address as six hex bytes separated by colons, most significant first, e.g. AA:BB:CC:DD:EE:FF - /// reads it back without the type + /// reads it back without the type; the format "T" keeps it /// The formatted address public override string ToString() => string.Join(':', Value.ToString("X12", CultureInfo.InvariantCulture).Chunk(2).Select(pair => new string(pair))); + /// The address in a format: null or empty as ; "T" with its type in front, P: for public and R: for random addresses + /// Only public and random addresses get a type; reads both forms back + /// Null, empty or "T" + /// Ignored + /// The formatted address + /// Thrown for any other format + public string ToString(string? format, IFormatProvider? formatProvider) => + format switch + { + null or "" => ToString(), + "T" => Type switch + { + BleAddressType.Public => $"P:{this}", + BleAddressType.Random => $"R:{this}", + _ => ToString(), + }, + _ => throw new FormatException($"The format '{format}' is not supported; use \"T\" or none"), + }; + /// public bool Equals(BleAddress? other) { diff --git a/src/Darp.Ble/Gap/AdvertisementStreamExtensions.cs b/src/Darp.Ble/Gap/AdvertisementStreamExtensions.cs index 873f0664..e9a36fb2 100644 --- a/src/Darp.Ble/Gap/AdvertisementStreamExtensions.cs +++ b/src/Darp.Ble/Gap/AdvertisementStreamExtensions.cs @@ -8,14 +8,19 @@ public static class AdvertisementStreamExtensions { /// Only advertisements from this address; all of them when the address is null /// The advertisements - /// The address to keep + /// The address to keep; without its type (), of any type /// The type of the advertisements /// The filtered advertisements public static IAsyncEnumerable WhereAddress(this IAsyncEnumerable source, BleAddress? address) where TAdv : IGapAdvertisement { ArgumentNullException.ThrowIfNull(source); - return address is null ? source : source.Where(adv => adv.Address.Equals(address)); + if (address is null) + return source; + // An address without its type, e.g. parsed from text, matches its value of any type + return address.Type is BleAddressType.NotAvailable + ? source.Where(adv => adv.Address.Value == address.Value) + : source.Where(adv => adv.Address.Equals(address)); } /// Only advertisements from this address value; all of them when the address is null diff --git a/src/Darp.Ble/Gap/AdvertisingSet.cs b/src/Darp.Ble/Gap/AdvertisingSet.cs index 20b44dfd..57ce7b24 100644 --- a/src/Darp.Ble/Gap/AdvertisingSet.cs +++ b/src/Darp.Ble/Gap/AdvertisingSet.cs @@ -9,7 +9,8 @@ namespace Darp.Ble.Gap; /// An advertising set: what an adapter advertises, and how /// /// Its operations run one at a time. Its state changes only after the backend accepted a change. -/// A central that connects through the set stops it; with autoRestart the set starts again once that central disconnected +/// A central that connects through the set stops it; with autoRestart the set starts again once that central disconnected. +/// A set the backend stopped because of an error, e.g. the radio turned off, stays stopped until it is started again /// [SuppressMessage( "Usage", @@ -95,14 +96,7 @@ public Task StartAsync(CancellationToken cancellationToken = default) => /// Cancels stopping /// A task that completes once the set stopped public Task StopAsync(CancellationToken cancellationToken = default) => - RunAsync( - () => - { - CancelPendingRestart(); - return StopCoreAsync(cancellationToken); - }, - cancellationToken - ); + RunAsync(() => StopCoreAsync(cancellationToken), cancellationToken); /// Changes how the set advertises. Controllers reject this while advertising /// The new parameters @@ -214,7 +208,8 @@ private async Task DisposeCoreAsync() { lock (_lock) _isDisposed = true; - CancelPendingRestart(); + // Closed first: a central connecting now admits no restart that disposal would wait for + CloseAdmission(); await StopCoreAsync(CancellationToken.None).ConfigureAwait(false); } finally @@ -261,6 +256,18 @@ internal void OnStoppedByConnection(ICentralConnectionDriver central) _ = RestartAfterDisconnectAsync(central, restart); } + /// The backend stopped the set because of an error. Called on the driver's thread + internal void OnFailed(BleException error) + { + lock (_lock) + { + if (!_isAdvertising) + return; + _isAdvertising = false; + } + _logger.LogAdvertisingFailed(RandomAddress, error); + } + /// Starts the set again once the central disconnected, unless a command or disposal cancelled the restart private async Task RestartAfterDisconnectAsync(ICentralConnectionDriver central, CancellationTokenSource restart) { @@ -355,8 +362,11 @@ private async Task StartCoreAsync(CancellationToken cancellationToken) private async Task StopCoreAsync(CancellationToken cancellationToken) { + // In one step with the connection callback's check: a central connecting now admits no restart lock (_lock) { + _pendingRestart?.Cancel(); + _pendingRestart = null; if (!_isAdvertising) return; _isStopping = true; diff --git a/src/Darp.Ble/Gap/AdvertisingSetEvents.cs b/src/Darp.Ble/Gap/AdvertisingSetEvents.cs index 0d6057e3..2facab4d 100644 --- a/src/Darp.Ble/Gap/AdvertisingSetEvents.cs +++ b/src/Darp.Ble/Gap/AdvertisingSetEvents.cs @@ -11,4 +11,6 @@ internal sealed class AdvertisingSetEvents : IAdvertisingSetSink public void OnStoppedByConnection(ICentralConnectionDriver central) => Volatile.Read(ref _set)?.OnStoppedByConnection(central); + + public void OnFailed(BleException error) => Volatile.Read(ref _set)?.OnFailed(error); } diff --git a/src/Darp.Ble/Gap/GapAdvertisement.cs b/src/Darp.Ble/Gap/GapAdvertisement.cs index 88b414a5..1f9c047e 100644 --- a/src/Darp.Ble/Gap/GapAdvertisement.cs +++ b/src/Darp.Ble/Gap/GapAdvertisement.cs @@ -1,31 +1,37 @@ -using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using Darp.Ble.Backends; using Darp.Ble.Data; namespace Darp.Ble.Gap; -/// An advertising report. Immutable: it owns one copy of the report's bytes, which every scan shares +/// A received advertisement; immutable, so every scan shares it public sealed class GapAdvertisement : IGapAdvertisement { - private const int HeaderLength = 24; - - private readonly ReadOnlyMemory _bytes; - - private GapAdvertisement(DateTimeOffset timestamp, ReadOnlyMemory bytes) + private GapAdvertisement( + DateTimeOffset timestamp, + BleEventType eventType, + BleAddress address, + Physical primaryPhy, + Physical secondaryPhy, + AdvertisingSId advertisingSId, + TxPowerLevel txPower, + Rssi rssi, + PeriodicAdvertisingInterval periodicAdvertisingInterval, + BleAddress directAddress, + AdvertisingData data + ) { - ReadOnlySpan span = bytes.Span; Timestamp = timestamp; - _bytes = bytes; - EventType = (BleEventType)BinaryPrimitives.ReadUInt16LittleEndian(span); - Address = new BleAddress((BleAddressType)span[2], UInt48.ReadLittleEndian(span[3..])); - PrimaryPhy = (Physical)span[9]; - SecondaryPhy = (Physical)span[10]; - AdvertisingSId = (AdvertisingSId)span[11]; - TxPower = (TxPowerLevel)span[12]; - Rssi = (Rssi)span[13]; - PeriodicAdvertisingInterval = (PeriodicAdvertisingInterval)BinaryPrimitives.ReadUInt16LittleEndian(span[14..]); - DirectAddress = new BleAddress((BleAddressType)span[16], UInt48.ReadLittleEndian(span[17..])); - byte dataLength = span[23]; - Data = AdvertisingData.FromOwned(bytes[HeaderLength..(HeaderLength + dataLength)]); + EventType = eventType; + Address = address; + PrimaryPhy = primaryPhy; + SecondaryPhy = secondaryPhy; + AdvertisingSId = advertisingSId; + TxPower = txPower; + Rssi = rssi; + PeriodicAdvertisingInterval = periodicAdvertisingInterval; + DirectAddress = directAddress; + Data = data; } /// @@ -61,19 +67,8 @@ private GapAdvertisement(DateTimeOffset timestamp, ReadOnlyMemory bytes) /// public AdvertisingData Data { get; } - /// - public ReadOnlyMemory AsReadOnlyMemory() => _bytes; - - /// Create an advertisement from the bytes of an extended advertising report. The bytes are copied - /// BLUETOOTH CORE SPECIFICATION Version 5.4 | Vol 4, Part E, 7.7.65.13 LE Extended Advertising Report event - /// The time the report was received - /// The bytes of a single extended advertising report - /// The advertisement - public static GapAdvertisement FromExtendedAdvertisingReport(DateTimeOffset timestamp, ReadOnlySpan report) => - new(timestamp, report.ToArray()); - - /// Create an advertisement from its values - /// The time the report was received + /// Create an advertisement; backends report what they received with it + /// The time the advertisement was received /// The type of the advertising event /// The address of the advertising device /// The primary physical the advertising device used @@ -83,9 +78,10 @@ public static GapAdvertisement FromExtendedAdvertisingReport(DateTimeOffset time /// The Rssi of the advertising device /// The interval of periodic advertisements /// The address of the device the advertisement is directed to - /// The data sections + /// The data sections of the whole advertisement /// The advertisement - public static GapAdvertisement FromExtendedAdvertisingReport( + [Experimental(Experiments.Backends)] + public static GapAdvertisement Create( DateTimeOffset timestamp, BleEventType eventType, BleAddress address, @@ -96,33 +92,39 @@ public static GapAdvertisement FromExtendedAdvertisingReport( Rssi rssi, PeriodicAdvertisingInterval periodicAdvertisingInterval, BleAddress directAddress, - AdvertisingData advertisingData + AdvertisingData data ) { ArgumentNullException.ThrowIfNull(address); ArgumentNullException.ThrowIfNull(directAddress); - ArgumentNullException.ThrowIfNull(advertisingData); - ReadOnlySpan dataSpan = advertisingData.AsReadOnlyMemory().Span; - var bytes = new byte[HeaderLength + dataSpan.Length]; - Span buffer = bytes; - BinaryPrimitives.WriteUInt16LittleEndian(buffer, (ushort)eventType); - buffer[2] = (byte)address.Type; - UInt48.WriteLittleEndian(buffer[3..], address.Value); - buffer[9] = (byte)primaryPhy; - buffer[10] = (byte)secondaryPhy; - buffer[11] = (byte)advertisingSId; - buffer[12] = (byte)txPower; - buffer[13] = (byte)rssi; - BinaryPrimitives.WriteUInt16LittleEndian(buffer[14..], (ushort)periodicAdvertisingInterval); - buffer[16] = (byte)directAddress.Type; - UInt48.WriteLittleEndian(buffer[17..], directAddress.Value); - buffer[23] = (byte)dataSpan.Length; - dataSpan.CopyTo(buffer[HeaderLength..]); - return new GapAdvertisement(timestamp, bytes); + ArgumentNullException.ThrowIfNull(data); + return new GapAdvertisement( + timestamp, + eventType, + address, + primaryPhy, + secondaryPhy, + advertisingSId, + txPower, + rssi, + periodicAdvertisingInterval, + directAddress, + data + ); } private bool Equals(GapAdvertisement other) => - Timestamp.Equals(other.Timestamp) && _bytes.Span.SequenceEqual(other._bytes.Span); + Timestamp.Equals(other.Timestamp) + && EventType == other.EventType + && Address.Equals(other.Address) + && PrimaryPhy == other.PrimaryPhy + && SecondaryPhy == other.SecondaryPhy + && AdvertisingSId == other.AdvertisingSId + && TxPower == other.TxPower + && Rssi == other.Rssi + && PeriodicAdvertisingInterval == other.PeriodicAdvertisingInterval + && DirectAddress.Equals(other.DirectAddress) + && Data.AsReadOnlyMemory().Span.SequenceEqual(other.Data.AsReadOnlyMemory().Span); /// public bool Equals(IGapAdvertisement? other) => @@ -135,10 +137,11 @@ public override bool Equals(object? obj) => /// public override int GetHashCode() { - // The same values as Equals: the timestamp and the bytes of the report + // A subset of what Equals compares: the timestamp, the sender and the data var hash = new HashCode(); hash.Add(Timestamp); - hash.AddBytes(_bytes.Span); + hash.Add(Address); + hash.AddBytes(Data.AsReadOnlyMemory().Span); return hash.ToHashCode(); } } @@ -191,9 +194,6 @@ public GapAdvertisement(IGapAdvertisement advertisement, TUserData userData) /// public AdvertisingData Data => _advertisement.Data; - /// - public ReadOnlyMemory AsReadOnlyMemory() => _advertisement.AsReadOnlyMemory(); - /// public TUserData UserData { get; } object? IGapAdvertisementWithUserData.UserData => UserData; diff --git a/src/Darp.Ble/Gap/IGapAdvertisement.cs b/src/Darp.Ble/Gap/IGapAdvertisement.cs index fe469597..90da8512 100644 --- a/src/Darp.Ble/Gap/IGapAdvertisement.cs +++ b/src/Darp.Ble/Gap/IGapAdvertisement.cs @@ -37,20 +37,8 @@ public interface IGapAdvertisement : IEquatable /// The address of the device the advertisement is directed to BleAddress DirectAddress { get; } - /// The data sections + /// The data sections of the whole advertisement; an HCI controller may report it in several parts, which the backend joins AdvertisingData Data { get; } - - /// The advertising report in the layout of a single report of the HCI LE Extended Advertising Report event - /// - /// The bytes start at Event_Type and do not include the event header. They are read-only; every scan shares them, and the call does not copy. - /// Only the HCI host receives these bytes from the controller. - /// Other backends, e.g. WinRT or Android, build them from the values the platform reports. - /// A value the platform does not report holds its "not available" value, - /// e.g. (0) for both PHYs on Windows, which a controller never reports as primary PHY. - /// Treat the bytes as a received HCI report only if the advertisement came from the HCI host. - /// - /// The bytes of the advertising report - ReadOnlyMemory AsReadOnlyMemory(); } /// An advertisement with attached user data diff --git a/src/Darp.Ble/Gatt/Server/AesCmac.cs b/src/Darp.Ble/Gatt/Server/AesCmac.cs index 9833240b..bf539ddd 100644 --- a/src/Darp.Ble/Gatt/Server/AesCmac.cs +++ b/src/Darp.Ble/Gatt/Server/AesCmac.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; +using Darp.Ble.Backends; namespace Darp.Ble.Gatt; /// The AES-CMAC encryption algorithm +[Experimental(Experiments.Backends)] public sealed class AesCmac : IDisposable { private readonly Aes _aes; diff --git a/src/Darp.Ble/Logging.cs b/src/Darp.Ble/Logging.cs index a4100ab4..f131ce5d 100644 --- a/src/Darp.Ble/Logging.cs +++ b/src/Darp.Ble/Logging.cs @@ -102,6 +102,14 @@ public static partial void LogAdvertisingRestartFailed( Exception exception ); + [LoggerMessage( + EventId = 1203, + EventName = "AdvertisingFailed", + Level = LogLevel.Warning, + Message = "Advertising set {Address} stopped because of an error" + )] + public static partial void LogAdvertisingFailed(this ILogger logger, BleAddress address, Exception exception); + [LoggerMessage( EventId = 1300, EventName = "Connecting", diff --git a/src/Darp.Ble/PublicAPI.Unshipped.txt b/src/Darp.Ble/PublicAPI.Unshipped.txt index 2a202efc..39763a75 100644 --- a/src/Darp.Ble/PublicAPI.Unshipped.txt +++ b/src/Darp.Ble/PublicAPI.Unshipped.txt @@ -1,94 +1,4 @@ #nullable enable -Darp.Ble.Backends.BleBackendContext -Darp.Ble.Backends.BleBackendContext.IncludeAddresses.get -> bool -Darp.Ble.Backends.BleBackendContext.LoggerFactory.get -> Microsoft.Extensions.Logging.ILoggerFactory! -Darp.Ble.Backends.BleBackendContext.TimeProvider.get -> System.TimeProvider! -Darp.Ble.Backends.GattAttributeDefinition -Darp.Ble.Backends.GattAttributeDefinition.Uuid.get -> Darp.Ble.Data.BleUuid! -Darp.Ble.Backends.GattCharacteristicDefinition -Darp.Ble.Backends.GattCharacteristicDefinition.Descriptors.get -> System.Collections.Generic.IReadOnlyList! -Darp.Ble.Backends.GattCharacteristicDefinition.Properties.get -> Darp.Ble.Gatt.GattProperty -Darp.Ble.Backends.GattDescriptorDefinition -Darp.Ble.Backends.GattDescriptorDefinition.StaticValue.get -> System.ReadOnlyMemory? -Darp.Ble.Backends.GattRequest -Darp.Ble.Backends.GattRequest.HandOffToken.get -> System.Threading.CancellationToken -Darp.Ble.Backends.GattRequest.IsAbandoned.get -> bool -Darp.Ble.Backends.GattRequest.OnTransactionAnswered() -> void -Darp.Ble.Backends.GattRequest.OnTransactionSent() -> void -Darp.Ble.Backends.GattServiceDefinition -Darp.Ble.Backends.GattServiceDefinition.Characteristics.get -> System.Collections.Generic.IReadOnlyList! -Darp.Ble.Backends.GattServiceDefinition.IsPrimary.get -> bool -Darp.Ble.Backends.GattServiceDefinition.Uuid.get -> Darp.Ble.Data.BleUuid! -Darp.Ble.Backends.IAdvertisingSetDriver -Darp.Ble.Backends.IAdvertisingSetDriver.RandomAddress.get -> Darp.Ble.Data.BleAddress! -Darp.Ble.Backends.IAdvertisingSetDriver.SetDataAsync(Darp.Ble.Gap.AdvertisingData! data, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IAdvertisingSetDriver.SetParametersAsync(Darp.Ble.Gap.AdvertisingParameters! parameters, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IAdvertisingSetDriver.SetRandomAddressAsync(Darp.Ble.Data.BleAddress! address, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IAdvertisingSetDriver.SetScanResponseDataAsync(Darp.Ble.Gap.AdvertisingData! data, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IAdvertisingSetDriver.StartAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IAdvertisingSetDriver.StopAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IAdvertisingSetSink -Darp.Ble.Backends.IAdvertisingSetSink.OnStoppedByConnection(Darp.Ble.Backends.ICentralConnectionDriver! central) -> void -Darp.Ble.Backends.IBleAdapterDriver -Darp.Ble.Backends.IBleAdapterDriver.Broadcaster.get -> Darp.Ble.Backends.IBroadcasterDriver? -Darp.Ble.Backends.IBleAdapterDriver.Central.get -> Darp.Ble.Backends.ICentralDriver? -Darp.Ble.Backends.IBleAdapterDriver.Features.get -> Darp.Ble.Capabilities -Darp.Ble.Backends.IBleAdapterDriver.Observer.get -> Darp.Ble.Backends.IObserverDriver? -Darp.Ble.Backends.IBleAdapterDriver.Peripheral.get -> Darp.Ble.Backends.IPeripheralDriver? -Darp.Ble.Backends.IBleAdapterDriver.RandomAddress.get -> Darp.Ble.Data.BleAddress! -Darp.Ble.Backends.IBleAdapterDriver.SetRandomAddressAsync(Darp.Ble.Data.BleAddress! address, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IBleAdapterSource -Darp.Ble.Backends.IBleAdapterSource.Name.get -> string? -Darp.Ble.Backends.IBleAdapterSource.OpenAsync(Darp.Ble.Backends.BleBackendContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IBleBackend -Darp.Ble.Backends.IBleBackend.EnumerateAdaptersAsync(Darp.Ble.Backends.BleBackendContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! -Darp.Ble.Backends.IBleBackend.Name.get -> string! -Darp.Ble.Backends.IBroadcasterDriver -Darp.Ble.Backends.IBroadcasterDriver.CreateAdvertisingSetAsync(Darp.Ble.Gap.AdvertisingParameters! parameters, Darp.Ble.Gap.AdvertisingData! data, Darp.Ble.Gap.AdvertisingData? scanResponseData, Darp.Ble.Backends.IAdvertisingSetSink! sink, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.ICentralConnectionDriver -Darp.Ble.Backends.ICentralConnectionDriver.Address.get -> Darp.Ble.Data.BleAddress! -Darp.Ble.Backends.ICentralDriver -Darp.Ble.Backends.ICentralDriver.ConnectAsync(Darp.Ble.Data.BleAddress! address, Darp.Ble.Connections.BleConnectionParameters! parameters, Darp.Ble.Backends.IConnectionSink! sink, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IConnectionDriver -Darp.Ble.Backends.IConnectionDriver.DiscoverServicesAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task!>! -Darp.Ble.Backends.IConnectionDriver.ExchangeMtuAsync(int clientMtu, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IConnectionSink -Darp.Ble.Backends.IConnectionSink.OnDisconnected(Darp.Ble.Connections.DisconnectInfo! info) -> void -Darp.Ble.Backends.IConnectionSink.OnNotification(Darp.Ble.Backends.IRemoteCharacteristicDriver! characteristic, System.ReadOnlySpan value) -> void -Darp.Ble.Backends.IGattServerSink -Darp.Ble.Backends.IGattServerSink.OnCentralConnected(Darp.Ble.Backends.ICentralConnectionDriver! central) -> void -Darp.Ble.Backends.IGattServerSink.OnCentralDisconnected(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Connections.DisconnectInfo! info) -> void -Darp.Ble.Backends.IGattServerSink.OnReadRequest(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattAttributeDefinition! attribute) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IGattServerSink.OnSubscriptionChanged(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattCharacteristicDefinition! characteristic, bool enabled) -> void -Darp.Ble.Backends.IGattServerSink.OnWriteCommand(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattAttributeDefinition! attribute, System.ReadOnlyMemory value) -> void -Darp.Ble.Backends.IGattServerSink.OnWriteRequest(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattAttributeDefinition! attribute, System.ReadOnlyMemory value) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IObserverDriver -Darp.Ble.Backends.IObserverDriver.StartScanAsync(Darp.Ble.Gap.BleScanParameters! parameters, Darp.Ble.Backends.IScanSink! sink, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IObserverDriver.StopScanAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IPeripheralDriver -Darp.Ble.Backends.IPeripheralDriver.AddServiceAsync(Darp.Ble.Backends.GattServiceDefinition! service, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IPeripheralDriver.Attach(Darp.Ble.Backends.IGattServerSink! sink) -> void -Darp.Ble.Backends.IPeripheralDriver.DisconnectAsync(Darp.Ble.Backends.ICentralConnectionDriver! central, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IPeripheralDriver.NotifyAsync(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattCharacteristicDefinition! characteristic, System.ReadOnlyMemory value, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask -Darp.Ble.Backends.IPeripheralDriver.SetGapIdentity(string! deviceName, Darp.Ble.Data.AssignedNumbers.AppearanceValues appearance) -> void -Darp.Ble.Backends.IRemoteCharacteristicDriver -Darp.Ble.Backends.IRemoteCharacteristicDriver.DiscoverDescriptorsAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task!>! -Darp.Ble.Backends.IRemoteCharacteristicDriver.Properties.get -> Darp.Ble.Gatt.GattProperty -Darp.Ble.Backends.IRemoteCharacteristicDriver.ReadAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IRemoteCharacteristicDriver.SetNotificationsAsync(bool enabled, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IRemoteCharacteristicDriver.Uuid.get -> Darp.Ble.Data.BleUuid! -Darp.Ble.Backends.IRemoteCharacteristicDriver.WriteAsync(System.ReadOnlyMemory value, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IRemoteCharacteristicDriver.WriteWithoutResponseAsync(System.ReadOnlyMemory value, System.Threading.CancellationToken cancellationToken, System.Threading.CancellationToken connectionClosed) -> System.Threading.Tasks.ValueTask -Darp.Ble.Backends.IRemoteDescriptorDriver -Darp.Ble.Backends.IRemoteDescriptorDriver.ReadAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IRemoteDescriptorDriver.Uuid.get -> Darp.Ble.Data.BleUuid! -Darp.Ble.Backends.IRemoteDescriptorDriver.WriteAsync(System.ReadOnlyMemory value, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! -Darp.Ble.Backends.IRemoteServiceDriver -Darp.Ble.Backends.IRemoteServiceDriver.DiscoverCharacteristicsAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task!>! -Darp.Ble.Backends.IRemoteServiceDriver.Uuid.get -> Darp.Ble.Data.BleUuid! -Darp.Ble.Backends.IScanSink -Darp.Ble.Backends.IScanSink.OnFailed(Darp.Ble.BleException! error) -> void -Darp.Ble.Backends.IScanSink.OnReport(Darp.Ble.Gap.IGapAdvertisement! advertisement) -> void Darp.Ble.BleAdapter Darp.Ble.BleAdapter.Backend.get -> string! Darp.Ble.BleAdapter.Broadcaster.get -> Darp.Ble.Gap.BleBroadcaster! @@ -116,7 +26,6 @@ Darp.Ble.BleManager Darp.Ble.BleManager.BleManager(System.Action! configure, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, System.TimeProvider? timeProvider = null) -> void Darp.Ble.BleManager.EnumerateAdaptersAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Darp.Ble.BleManagerBuilder -Darp.Ble.BleManagerBuilder.AddBackend(Darp.Ble.Backends.IBleBackend! backend) -> Darp.Ble.BleManagerBuilder! Darp.Ble.BleManagerBuilder.ConfigureDiagnostics(System.Action! configure) -> Darp.Ble.BleManagerBuilder! Darp.Ble.BleManagerBuilder.ConfigureQueues(System.Action! configure) -> Darp.Ble.BleManagerBuilder! Darp.Ble.BleManagerServiceCollectionExtensions @@ -3914,6 +3823,7 @@ Darp.Ble.Data.BleAddress.Equals(Darp.Ble.Data.BleAddress? other) -> bool Darp.Ble.Data.BleAddress.Equals(Darp.Ble.Data.UInt48 other) -> bool Darp.Ble.Data.BleAddress.Equals(ulong other) -> bool Darp.Ble.Data.BleAddress.RandomKind.get -> Darp.Ble.Data.BleRandomAddressKind? +Darp.Ble.Data.BleAddress.ToString(string? format, System.IFormatProvider? formatProvider) -> string! Darp.Ble.Data.BleAddress.Type.get -> Darp.Ble.Data.BleAddressType Darp.Ble.Data.BleAddress.Type.init -> void Darp.Ble.Data.BleAddress.Value.get -> Darp.Ble.Data.UInt48 @@ -4069,7 +3979,6 @@ Darp.Ble.Gap.BleScanParameters.ScanWindow.init -> void Darp.Ble.Gap.GapAdvertisement Darp.Ble.Gap.GapAdvertisement.Address.get -> Darp.Ble.Data.BleAddress! Darp.Ble.Gap.GapAdvertisement.AdvertisingSId.get -> Darp.Ble.Gap.AdvertisingSId -Darp.Ble.Gap.GapAdvertisement.AsReadOnlyMemory() -> System.ReadOnlyMemory Darp.Ble.Gap.GapAdvertisement.Data.get -> Darp.Ble.Gap.AdvertisingData! Darp.Ble.Gap.GapAdvertisement.DirectAddress.get -> Darp.Ble.Data.BleAddress! Darp.Ble.Gap.GapAdvertisement.Equals(Darp.Ble.Gap.IGapAdvertisement? other) -> bool @@ -4083,7 +3992,6 @@ Darp.Ble.Gap.GapAdvertisement.TxPower.get -> Darp.Ble.Data.TxPowerLevel Darp.Ble.Gap.GapAdvertisement Darp.Ble.Gap.GapAdvertisement.Address.get -> Darp.Ble.Data.BleAddress! Darp.Ble.Gap.GapAdvertisement.AdvertisingSId.get -> Darp.Ble.Gap.AdvertisingSId -Darp.Ble.Gap.GapAdvertisement.AsReadOnlyMemory() -> System.ReadOnlyMemory Darp.Ble.Gap.GapAdvertisement.Data.get -> Darp.Ble.Gap.AdvertisingData! Darp.Ble.Gap.GapAdvertisement.DirectAddress.get -> Darp.Ble.Data.BleAddress! Darp.Ble.Gap.GapAdvertisement.Equals(Darp.Ble.Gap.IGapAdvertisement? other) -> bool @@ -4099,7 +4007,6 @@ Darp.Ble.Gap.GapAdvertisement.UserData.get -> TUserData Darp.Ble.Gap.IGapAdvertisement Darp.Ble.Gap.IGapAdvertisement.Address.get -> Darp.Ble.Data.BleAddress! Darp.Ble.Gap.IGapAdvertisement.AdvertisingSId.get -> Darp.Ble.Gap.AdvertisingSId -Darp.Ble.Gap.IGapAdvertisement.AsReadOnlyMemory() -> System.ReadOnlyMemory Darp.Ble.Gap.IGapAdvertisement.Data.get -> Darp.Ble.Gap.AdvertisingData! Darp.Ble.Gap.IGapAdvertisement.DirectAddress.get -> Darp.Ble.Data.BleAddress! Darp.Ble.Gap.IGapAdvertisement.EventType.get -> Darp.Ble.Gap.BleEventType @@ -4118,10 +4025,6 @@ Darp.Ble.Gap.PeriodicAdvertisingInterval.NoPeriodicAdvertising = 0 -> Darp.Ble.G Darp.Ble.Gap.ScanType Darp.Ble.Gap.ScanType.Active = 1 -> Darp.Ble.Gap.ScanType Darp.Ble.Gap.ScanType.Passive = 0 -> Darp.Ble.Gap.ScanType -Darp.Ble.Gatt.AesCmac -Darp.Ble.Gatt.AesCmac.AesCmac(System.ReadOnlySpan key) -> void -Darp.Ble.Gatt.AesCmac.Dispose() -> void -Darp.Ble.Gatt.AesCmac.Encrypt(System.ReadOnlySpan message) -> byte[]! Darp.Ble.Gatt.CharacteristicDeclaration Darp.Ble.Gatt.CharacteristicDeclaration Darp.Ble.Gatt.CharacteristicDeclaration.CharacteristicDeclaration(Darp.Ble.Data.BleUuid! uuid) -> void @@ -4134,9 +4037,9 @@ Darp.Ble.Gatt.DescriptorDeclaration.DescriptorDeclaration(Darp.Ble.Data.BleUuid! Darp.Ble.Gatt.DescriptorDeclaration.Uuid.get -> Darp.Ble.Data.BleUuid! Darp.Ble.Gatt.GattErrorKind Darp.Ble.Gatt.GattErrorKind.AttError = 0 -> Darp.Ble.Gatt.GattErrorKind +Darp.Ble.Gatt.GattErrorKind.InvalidValue = 3 -> Darp.Ble.Gatt.GattErrorKind Darp.Ble.Gatt.GattErrorKind.NotFound = 1 -> Darp.Ble.Gatt.GattErrorKind Darp.Ble.Gatt.GattErrorKind.PropertyMismatch = 2 -> Darp.Ble.Gatt.GattErrorKind -Darp.Ble.Gatt.GattErrorKind.InvalidValue = 3 -> Darp.Ble.Gatt.GattErrorKind Darp.Ble.Gatt.GattException Darp.Ble.Gatt.GattException.AttStatus.get -> Darp.Ble.Gatt.GattProtocolStatus? Darp.Ble.Gatt.GattException.GattException(Darp.Ble.Gatt.GattErrorKind kind, string! message) -> void @@ -4472,8 +4375,107 @@ Darp.Ble.QueueOptions Darp.Ble.QueueOptions.Capacity.get -> int? Darp.Ble.QueueOptions.FullMode.get -> Darp.Ble.QueueFullMode Darp.Ble.QueueOptions.QueueOptions(int capacity, Darp.Ble.QueueFullMode fullMode) -> void +[DARPBLE0001]Darp.Ble.Backends.BleBackendContext +[DARPBLE0001]Darp.Ble.Backends.BleBackendContext.IncludeAddresses.get -> bool +[DARPBLE0001]Darp.Ble.Backends.BleBackendContext.LoggerFactory.get -> Microsoft.Extensions.Logging.ILoggerFactory! +[DARPBLE0001]Darp.Ble.Backends.BleBackendContext.TimeProvider.get -> System.TimeProvider! +[DARPBLE0001]Darp.Ble.Backends.GattAttributeDefinition +[DARPBLE0001]Darp.Ble.Backends.GattAttributeDefinition.Uuid.get -> Darp.Ble.Data.BleUuid! +[DARPBLE0001]Darp.Ble.Backends.GattCharacteristicDefinition +[DARPBLE0001]Darp.Ble.Backends.GattCharacteristicDefinition.Descriptors.get -> System.Collections.Generic.IReadOnlyList! +[DARPBLE0001]Darp.Ble.Backends.GattCharacteristicDefinition.Properties.get -> Darp.Ble.Gatt.GattProperty +[DARPBLE0001]Darp.Ble.Backends.GattDescriptorDefinition +[DARPBLE0001]Darp.Ble.Backends.GattDescriptorDefinition.StaticValue.get -> System.ReadOnlyMemory? +[DARPBLE0001]Darp.Ble.Backends.GattRequest +[DARPBLE0001]Darp.Ble.Backends.GattRequest.HandOffToken.get -> System.Threading.CancellationToken +[DARPBLE0001]Darp.Ble.Backends.GattRequest.IsAbandoned.get -> bool +[DARPBLE0001]Darp.Ble.Backends.GattRequest.OnTransactionAnswered() -> void +[DARPBLE0001]Darp.Ble.Backends.GattRequest.OnTransactionSent() -> void +[DARPBLE0001]Darp.Ble.Backends.GattServiceDefinition +[DARPBLE0001]Darp.Ble.Backends.GattServiceDefinition.Characteristics.get -> System.Collections.Generic.IReadOnlyList! +[DARPBLE0001]Darp.Ble.Backends.GattServiceDefinition.IsPrimary.get -> bool +[DARPBLE0001]Darp.Ble.Backends.GattServiceDefinition.Uuid.get -> Darp.Ble.Data.BleUuid! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.RandomAddress.get -> Darp.Ble.Data.BleAddress! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.SetDataAsync(Darp.Ble.Gap.AdvertisingData! data, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.SetParametersAsync(Darp.Ble.Gap.AdvertisingParameters! parameters, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.SetRandomAddressAsync(Darp.Ble.Data.BleAddress! address, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.SetScanResponseDataAsync(Darp.Ble.Gap.AdvertisingData! data, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.StartAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetDriver.StopAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetSink +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetSink.OnFailed(Darp.Ble.BleException! error) -> void +[DARPBLE0001]Darp.Ble.Backends.IAdvertisingSetSink.OnStoppedByConnection(Darp.Ble.Backends.ICentralConnectionDriver! central) -> void +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.Broadcaster.get -> Darp.Ble.Backends.IBroadcasterDriver? +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.Central.get -> Darp.Ble.Backends.ICentralDriver? +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.Features.get -> Darp.Ble.Capabilities +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.Observer.get -> Darp.Ble.Backends.IObserverDriver? +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.Peripheral.get -> Darp.Ble.Backends.IPeripheralDriver? +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.RandomAddress.get -> Darp.Ble.Data.BleAddress! +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterDriver.SetRandomAddressAsync(Darp.Ble.Data.BleAddress! address, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterSource +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterSource.Name.get -> string? +[DARPBLE0001]Darp.Ble.Backends.IBleAdapterSource.OpenAsync(Darp.Ble.Backends.BleBackendContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IBleBackend +[DARPBLE0001]Darp.Ble.Backends.IBleBackend.EnumerateAdaptersAsync(Darp.Ble.Backends.BleBackendContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +[DARPBLE0001]Darp.Ble.Backends.IBleBackend.Name.get -> string! +[DARPBLE0001]Darp.Ble.Backends.IBroadcasterDriver +[DARPBLE0001]Darp.Ble.Backends.IBroadcasterDriver.CreateAdvertisingSetAsync(Darp.Ble.Gap.AdvertisingParameters! parameters, Darp.Ble.Gap.AdvertisingData! data, Darp.Ble.Gap.AdvertisingData? scanResponseData, Darp.Ble.Backends.IAdvertisingSetSink! sink, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.ICentralConnectionDriver +[DARPBLE0001]Darp.Ble.Backends.ICentralConnectionDriver.Address.get -> Darp.Ble.Data.BleAddress! +[DARPBLE0001]Darp.Ble.Backends.ICentralDriver +[DARPBLE0001]Darp.Ble.Backends.ICentralDriver.ConnectAsync(Darp.Ble.Data.BleAddress! address, Darp.Ble.Connections.BleConnectionParameters! parameters, Darp.Ble.Backends.IConnectionSink! sink, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IConnectionDriver +[DARPBLE0001]Darp.Ble.Backends.IConnectionDriver.DiscoverServicesAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task!>! +[DARPBLE0001]Darp.Ble.Backends.IConnectionDriver.ExchangeMtuAsync(int clientMtu, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IConnectionSink +[DARPBLE0001]Darp.Ble.Backends.IConnectionSink.OnDisconnected(Darp.Ble.Connections.DisconnectInfo! info) -> void +[DARPBLE0001]Darp.Ble.Backends.IConnectionSink.OnNotification(Darp.Ble.Backends.IRemoteCharacteristicDriver! characteristic, System.ReadOnlySpan value) -> void +[DARPBLE0001]Darp.Ble.Backends.IConnectionSink.OnMtuChanged(int mtu) -> void +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink.OnCentralConnected(Darp.Ble.Backends.ICentralConnectionDriver! central) -> void +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink.OnCentralDisconnected(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Connections.DisconnectInfo! info) -> void +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink.OnReadRequest(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattAttributeDefinition! attribute) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink.OnSubscriptionChanged(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattCharacteristicDefinition! characteristic, bool enabled) -> void +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink.OnWriteCommand(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattAttributeDefinition! attribute, System.ReadOnlyMemory value) -> void +[DARPBLE0001]Darp.Ble.Backends.IGattServerSink.OnWriteRequest(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattAttributeDefinition! attribute, System.ReadOnlyMemory value) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IObserverDriver +[DARPBLE0001]Darp.Ble.Backends.IObserverDriver.StartScanAsync(Darp.Ble.Gap.BleScanParameters! parameters, Darp.Ble.Backends.IScanSink! sink, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IObserverDriver.StopScanAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IPeripheralDriver +[DARPBLE0001]Darp.Ble.Backends.IPeripheralDriver.AddServiceAsync(Darp.Ble.Backends.GattServiceDefinition! service, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IPeripheralDriver.Attach(Darp.Ble.Backends.IGattServerSink! sink) -> void +[DARPBLE0001]Darp.Ble.Backends.IPeripheralDriver.DisconnectAsync(Darp.Ble.Backends.ICentralConnectionDriver! central, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IPeripheralDriver.NotifyAsync(Darp.Ble.Backends.ICentralConnectionDriver! central, Darp.Ble.Backends.GattCharacteristicDefinition! characteristic, System.ReadOnlyMemory value, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +[DARPBLE0001]Darp.Ble.Backends.IPeripheralDriver.SetGapIdentity(string! deviceName, Darp.Ble.Data.AssignedNumbers.AppearanceValues appearance) -> void +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.DiscoverDescriptorsAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task!>! +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.Properties.get -> Darp.Ble.Gatt.GattProperty +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.ReadAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.SetNotificationsAsync(bool enabled, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.Uuid.get -> Darp.Ble.Data.BleUuid! +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.WriteAsync(System.ReadOnlyMemory value, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IRemoteCharacteristicDriver.WriteWithoutResponseAsync(System.ReadOnlyMemory value, System.Threading.CancellationToken cancellationToken, System.Threading.CancellationToken connectionClosed) -> System.Threading.Tasks.ValueTask +[DARPBLE0001]Darp.Ble.Backends.IRemoteDescriptorDriver +[DARPBLE0001]Darp.Ble.Backends.IRemoteDescriptorDriver.ReadAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IRemoteDescriptorDriver.Uuid.get -> Darp.Ble.Data.BleUuid! +[DARPBLE0001]Darp.Ble.Backends.IRemoteDescriptorDriver.WriteAsync(System.ReadOnlyMemory value, Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task! +[DARPBLE0001]Darp.Ble.Backends.IRemoteServiceDriver +[DARPBLE0001]Darp.Ble.Backends.IRemoteServiceDriver.DiscoverCharacteristicsAsync(Darp.Ble.Backends.GattRequest! request) -> System.Threading.Tasks.Task!>! +[DARPBLE0001]Darp.Ble.Backends.IRemoteServiceDriver.Uuid.get -> Darp.Ble.Data.BleUuid! +[DARPBLE0001]Darp.Ble.Backends.IScanSink +[DARPBLE0001]Darp.Ble.Backends.IScanSink.OnFailed(Darp.Ble.BleException! error) -> void +[DARPBLE0001]Darp.Ble.Backends.IScanSink.OnReport(Darp.Ble.Gap.IGapAdvertisement! advertisement) -> void +[DARPBLE0001]Darp.Ble.BleManagerBuilder.AddBackend(Darp.Ble.Backends.IBleBackend! backend) -> Darp.Ble.BleManagerBuilder! +[DARPBLE0001]Darp.Ble.Gatt.AesCmac +[DARPBLE0001]Darp.Ble.Gatt.AesCmac.AesCmac(System.ReadOnlySpan key) -> void +[DARPBLE0001]Darp.Ble.Gatt.AesCmac.Dispose() -> void +[DARPBLE0001]Darp.Ble.Gatt.AesCmac.Encrypt(System.ReadOnlySpan message) -> byte[]! +[DARPBLE0001]static Darp.Ble.Gap.GapAdvertisement.Create(System.DateTimeOffset timestamp, Darp.Ble.Gap.BleEventType eventType, Darp.Ble.Data.BleAddress! address, Darp.Ble.Data.Physical primaryPhy, Darp.Ble.Data.Physical secondaryPhy, Darp.Ble.Gap.AdvertisingSId advertisingSId, Darp.Ble.Data.TxPowerLevel txPower, Darp.Ble.Data.Rssi rssi, Darp.Ble.Gap.PeriodicAdvertisingInterval periodicAdvertisingInterval, Darp.Ble.Data.BleAddress! directAddress, Darp.Ble.Gap.AdvertisingData! data) -> Darp.Ble.Gap.GapAdvertisement! const Darp.Ble.BleBackends.HciHost = "Darp.Ble.HciHost" -> string! const Darp.Ble.BleBackends.Mock = "Darp.Ble.Mock" -> string! +const Darp.Ble.BleBackends.WinRT = "Darp.Ble.WinRT" -> string! const Darp.Ble.Diagnostics.BleTelemetry.MeterName = "Darp.Ble" -> string! const Darp.Ble.Diagnostics.BleTelemetry.SourceName = "Darp.Ble" -> string! override Darp.Ble.Connections.BleConnectionParameters.Equals(object? obj) -> bool @@ -4616,8 +4618,6 @@ static Darp.Ble.Gap.AdvertisingParameters.operator !=(Darp.Ble.Gap.AdvertisingPa static Darp.Ble.Gap.AdvertisingParameters.operator ==(Darp.Ble.Gap.AdvertisingParameters? left, Darp.Ble.Gap.AdvertisingParameters? right) -> bool static Darp.Ble.Gap.BleScanParameters.operator !=(Darp.Ble.Gap.BleScanParameters? left, Darp.Ble.Gap.BleScanParameters? right) -> bool static Darp.Ble.Gap.BleScanParameters.operator ==(Darp.Ble.Gap.BleScanParameters? left, Darp.Ble.Gap.BleScanParameters? right) -> bool -static Darp.Ble.Gap.GapAdvertisement.FromExtendedAdvertisingReport(System.DateTimeOffset timestamp, Darp.Ble.Gap.BleEventType eventType, Darp.Ble.Data.BleAddress! address, Darp.Ble.Data.Physical primaryPhy, Darp.Ble.Data.Physical secondaryPhy, Darp.Ble.Gap.AdvertisingSId advertisingSId, Darp.Ble.Data.TxPowerLevel txPower, Darp.Ble.Data.Rssi rssi, Darp.Ble.Gap.PeriodicAdvertisingInterval periodicAdvertisingInterval, Darp.Ble.Data.BleAddress! directAddress, Darp.Ble.Gap.AdvertisingData! advertisingData) -> Darp.Ble.Gap.GapAdvertisement! -static Darp.Ble.Gap.GapAdvertisement.FromExtendedAdvertisingReport(System.DateTimeOffset timestamp, System.ReadOnlySpan report) -> Darp.Ble.Gap.GapAdvertisement! static Darp.Ble.Gatt.CharacteristicDeclaration.Create(Darp.Ble.Data.BleUuid! uuid) -> Darp.Ble.Gatt.TypedCharacteristicDeclaration! static Darp.Ble.Gatt.CharacteristicDeclaration.Create(Darp.Ble.Data.BleUuid! uuid, Darp.Ble.Gatt.IGattTypedCharacteristic.DecodeFunc! decode, Darp.Ble.Gatt.IGattTypedCharacteristic.EncodeFunc! encode) -> Darp.Ble.Gatt.TypedCharacteristicDeclaration! static Darp.Ble.Gatt.CharacteristicDeclaration.Create(Darp.Ble.Data.BleUuid! uuid) -> Darp.Ble.Gatt.TypedCharacteristicDeclaration! diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a2fd8677..f3966e45 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -22,6 +22,10 @@ $(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037 + + + $(NoWarn);DARPBLE0001 + diff --git a/test/Darp.Ble.Hci.Tests/Darp.Ble.Hci.Tests.csproj b/test/Darp.Ble.Hci.Tests/Darp.Ble.Hci.Tests.csproj index 6a93167d..5c9f107b 100644 --- a/test/Darp.Ble.Hci.Tests/Darp.Ble.Hci.Tests.csproj +++ b/test/Darp.Ble.Hci.Tests/Darp.Ble.Hci.Tests.csproj @@ -1,7 +1,6 @@ net10.0 - false Exe true diff --git a/test/Darp.Ble.HciHost.Tests/CentralTests.cs b/test/Darp.Ble.HciHost.Tests/CentralTests.cs index c540ff33..3676c308 100644 --- a/test/Darp.Ble.HciHost.Tests/CentralTests.cs +++ b/test/Darp.Ble.HciHost.Tests/CentralTests.cs @@ -2,10 +2,12 @@ using Darp.BinaryObjects; using Darp.Ble.Connections; using Darp.Ble.Data; +using Darp.Ble.Gap; using Darp.Ble.Gatt; using Darp.Ble.Hci.Package; using Darp.Ble.Hci.Payload; using Darp.Ble.Hci.Payload.Att; +using Darp.Ble.Hci.Payload.Event; using Darp.Ble.HciHost.Verify; using Microsoft.Extensions.Time.Testing; using Shouldly; @@ -33,6 +35,62 @@ public async Task Connecting_CreatesTheLinkAndAgreesOnTheMtu_AndDisposingTakesTh await Verifier.Verify(controller.Messages); } + [Fact] + public async Task AnAddressWithoutItsType_ConnectsWithTheTypeItWasHeardWith() + { + await using var controller = new FakeController(); + controller.Answer( + HciOpCode.HCI_LE_Create_Connection_Cancel, + _ => + [ + HciMessage.CommandCompleteEventToHost("010E2000"), + HciAdapter.ConnectionComplete(0, HciCommandStatus.UnknownConnectionIdentifier), + ] + ); + await using BleAdapter adapter = await HciAdapter.OpenAsync(controller); + // A legacy report from the random address EE:66:19:91:C4:15 + await using ( + IAsyncEnumerator scan = adapter.Observer.ScanAsync(Token).GetAsyncEnumerator(Token) + ) + { + ValueTask next = scan.MoveNextAsync(); + await controller.WaitForCommandAsync(HciOpCode.HCI_LE_Set_Extended_Scan_Enable); + await controller.PushAsync( + HciMessage.LeEventToHost("0D0110000115C4911966EE0100FF7FB40000FF0000000000000807FF4C0012020000") + ); + (await next).ShouldBeTrue(); + } + using var cancel = CancellationTokenSource.CreateLinkedTokenSource(Token); + + Task connecting = adapter.Central.ConnectAsync( + BleAddress.Parse("EE:66:19:91:C4:15", provider: null), + cancellationToken: cancel.Token + ); + HciMessage command = await controller.WaitForCommandAsync(HciOpCode.HCI_LE_Extended_Create_Connection_V1); + await cancel.CancelAsync(); + + // After the opcode and the length: Initiator_Filter_Policy, Own_Address_Type, then Peer_Address_Type + command.PduBytes[5].ShouldBe((byte)BleAddressType.Random); + await Should.ThrowAsync(connecting); + } + + [Fact] + public async Task AnAddressWithoutItsType_ThatWasNeverHeard_IsUnreachable() + { + await using var controller = new FakeController(); + await using BleAdapter adapter = await HciAdapter.OpenAsync(controller); + + BleConnectException exception = await Should.ThrowAsync( + adapter.Central.ConnectAsync( + BleAddress.Parse("11:22:33:44:55:66", provider: null), + cancellationToken: Token + ) + ); + + exception.Kind.ShouldBe(BleConnectErrorKind.Unreachable); + controller.Commands.ShouldNotContain(HciOpCode.HCI_LE_Extended_Create_Connection_V1); + } + [Fact] public async Task CancellingAnAttempt_CancelsItAtTheController() { @@ -146,8 +204,11 @@ public async Task AControllerBusyConnecting_RefusesTheAttempt() exception.NativeCode.ShouldBe(0x0C); } - [Fact] - public async Task AnAttemptTheControllerGivesUp_IsUnreachable() + // Of a failed event only the status is valid, so its role may be anything + [Theory] + [InlineData(HciLeConnectionRole.Central)] + [InlineData(HciLeConnectionRole.Peripheral)] + public async Task AnAttemptTheControllerGivesUp_IsUnreachable(HciLeConnectionRole role) { await using var controller = new FakeController(); await using BleAdapter adapter = await HciAdapter.OpenAsync(controller); @@ -155,7 +216,11 @@ public async Task AnAttemptTheControllerGivesUp_IsUnreachable() await controller.WaitForCommandAsync(HciOpCode.HCI_LE_Extended_Create_Connection_V1); await controller.PushAsync( - HciAdapter.ConnectionComplete(0, HciCommandStatus.ConnectionFailedToBeEstablishedOrSynchronizationTimeout) + HciAdapter.ConnectionComplete( + 0, + HciCommandStatus.ConnectionFailedToBeEstablishedOrSynchronizationTimeout, + role + ) ); BleConnectException exception = await Should.ThrowAsync(connecting); diff --git a/test/Darp.Ble.HciHost.Tests/ObserverTests.cs b/test/Darp.Ble.HciHost.Tests/ObserverTests.cs index 9cce2d87..d489191c 100644 --- a/test/Darp.Ble.HciHost.Tests/ObserverTests.cs +++ b/test/Darp.Ble.HciHost.Tests/ObserverTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using Darp.Ble.Data; using Darp.Ble.Gap; using Darp.Ble.Hci.Package; @@ -104,4 +105,147 @@ public async Task ALostTransport_EndsTheScan() BleException exception = await Should.ThrowAsync(() => scan.MoveNextAsync().AsTask()); exception.InnerException.ShouldBeOfType(); } + + // Event type bits of extended reports: the scan response flag, and the data status in bits 5 and 6 + private const ushort ScanResponse = 0b01000; + private const ushort MoreDataToCome = 0b01 << 5; + private const ushort Truncated = 0b10 << 5; + + // Ten manufacturer data sections of 31 bytes each, so 310 bytes; a controller has to split them + private static readonly byte[] LongData = + [ + .. Enumerable.Range(0, 10).SelectMany(i => (byte[])[0x1E, 0xFF, .. Enumerable.Repeat((byte)i, 29)]), + ]; + + /// One extended report from a random address, with advertising SID 1 + private static HciMessage ExtendedReport( + ushort eventType, + ulong address, + ReadOnlySpan data, + sbyte txPower = 0x7F + ) + { + byte[] parameters = new byte[2 + 24 + data.Length]; + parameters[0] = 0x0D; + parameters[1] = 1; + Span report = parameters.AsSpan(2); + BinaryPrimitives.WriteUInt16LittleEndian(report, eventType); + report[2] = 0x01; + BinaryPrimitives.WriteUInt64LittleEndian(report[3..11], address); + report[9] = 0x01; + report[10] = 0x02; + report[11] = 0x01; + report[12] = (byte)txPower; + report[13] = 0xB4; + report[16] = 0xFF; + report[23] = (byte)data.Length; + data.CopyTo(report[24..]); + return HciMessage.LeEventToHost(parameters); + } + + private static async Task> ScanAsync(int count, params HciMessage[] events) + { + await using var controller = new FakeController(); + await using BleAdapter adapter = await HciAdapter.OpenAsync(controller); + var heard = new List(); + await using IAsyncEnumerator scan = adapter + .Observer.ScanAsync(Token) + .GetAsyncEnumerator(Token); + ValueTask first = scan.MoveNextAsync(); + await controller.WaitForCommandAsync(HciOpCode.HCI_LE_Set_Extended_Scan_Enable); + foreach (HciMessage message in events) + await controller.PushAsync(message); + (await first).ShouldBeTrue(); + heard.Add(scan.Current); + while (heard.Count < count) + { + (await scan.MoveNextAsync()).ShouldBeTrue(); + heard.Add(scan.Current); + } + return heard; + } + + [Fact] + public async Task AnAdvertisementInSeveralReports_ArrivesAsOne() + { + List heard = await ScanAsync( + 1, + ExtendedReport(MoreDataToCome, 0xC0FFEE, LongData.AsSpan(0, 200)), + ExtendedReport(0, 0xC0FFEE, LongData.AsSpan(200)) + ); + + IGapAdvertisement advertisement = heard.ShouldHaveSingleItem(); + advertisement.Data.ToByteArray().ShouldBe(LongData); + advertisement.Data.Count.ShouldBe(10); + advertisement.EventType.ShouldBe(BleEventType.None); + } + + [Fact] + public async Task ReportsOfAnotherAdvertiser_InBetween_StayApart() + { + List heard = await ScanAsync( + 2, + ExtendedReport(MoreDataToCome, 0xC0FFEE, LongData.AsSpan(0, 200)), + ExtendedReport(0, 0xBEEF, [0x02, 0x01, 0x06]), + ExtendedReport(0, 0xC0FFEE, LongData.AsSpan(200)) + ); + + heard[0].Address.Value.ShouldBe((UInt48)0xBEEF); + heard[0].Data.ToByteArray().ShouldBe([0x02, 0x01, 0x06]); + heard[1].Address.Value.ShouldBe((UInt48)0xC0FFEE); + heard[1].Data.ToByteArray().ShouldBe(LongData); + } + + [Fact] + public async Task AScanResponse_IsNotJoinedWithTheAdvertisement() + { + List heard = await ScanAsync( + 2, + ExtendedReport(MoreDataToCome, 0xC0FFEE, LongData.AsSpan(0, 200)), + ExtendedReport(ScanResponse, 0xC0FFEE, [0x02, 0x01, 0x06]), + ExtendedReport(0, 0xC0FFEE, LongData.AsSpan(200)) + ); + + heard[0].EventType.ShouldBe(BleEventType.ScanResponse); + heard[0].Data.ToByteArray().ShouldBe([0x02, 0x01, 0x06]); + heard[1].Data.ToByteArray().ShouldBe(LongData); + } + + [Fact] + public async Task TruncatedData_ArrivesAsTheControllerReceivedIt() + { + List heard = await ScanAsync( + 1, + ExtendedReport(MoreDataToCome, 0xC0FFEE, LongData.AsSpan(0, 124)), + ExtendedReport(Truncated, 0xC0FFEE, LongData.AsSpan(124, 62)) + ); + + heard.ShouldHaveSingleItem().Data.ToByteArray().ShouldBe(LongData[..186]); + } + + [Fact] + public async Task TheTxPowerOfAnEarlierPart_StaysWhenLaterPartsLeaveItOut() + { + List heard = await ScanAsync( + 1, + ExtendedReport(MoreDataToCome, 0xC0FFEE, LongData.AsSpan(0, 200), txPower: 4), + ExtendedReport(0, 0xC0FFEE, LongData.AsSpan(200)) + ); + + heard.ShouldHaveSingleItem().TxPower.ShouldBe((TxPowerLevel)4); + } + + [Fact] + public async Task AnAdvertisementLongerThanAnyCanBe_IsDroppedWithAllItsParts() + { + HciMessage[] tooLong = + [ + .. Enumerable.Range(0, 9).Select(_ => ExtendedReport(MoreDataToCome, 0xC0FFEE, new byte[200])), + ExtendedReport(0, 0xC0FFEE, [0x02, 0x01, 0x06]), + ]; + + List heard = await ScanAsync(1, [.. tooLong, ExtendedReport(0, 0xBEEF, [0x02, 0x01, 0x06])]); + + heard.ShouldHaveSingleItem().Address.Value.ShouldBe((UInt48)0xBEEF); + } } diff --git a/test/Darp.Ble.Tests/Advertising/BleBroadcasterTests.cs b/test/Darp.Ble.Tests/Advertising/BleBroadcasterTests.cs index dee6b1d5..8811bf6f 100644 --- a/test/Darp.Ble.Tests/Advertising/BleBroadcasterTests.cs +++ b/test/Darp.Ble.Tests/Advertising/BleBroadcasterTests.cs @@ -115,6 +115,23 @@ await Should.ThrowAsync(() => _driver.Sets[0].Calls.ShouldBe(["start", "stop", "parameters", "start"]); } + [Fact] + public async Task StartAsync_AfterTheBackendStoppedTheSet_StartsItAgain() + { + await using BleAdapter adapter = await OpenAsync(); + AdvertisingSet set = await adapter.Broadcaster.StartAdvertisingAsync( + AdvertisingData.Empty, + TimeSpan.FromSeconds(1), + cancellationToken: Token + ); + TestAdvertisingSetDriver driver = _driver.Sets.ShouldHaveSingleItem(); + + driver.Sink.OnFailed(new BleException("The radio turned off")); + await set.StartAsync(Token); + + driver.Calls.ShouldBe(["start", "start"]); + } + [Fact] public async Task SetAdvertisingDataAsync_WhenTheDriverRejectsIt_KeepsTheOldData() { diff --git a/test/Darp.Ble.Tests/Advertising/TestAdvertisingSetDriver.cs b/test/Darp.Ble.Tests/Advertising/TestAdvertisingSetDriver.cs index fae73f7c..77f26686 100644 --- a/test/Darp.Ble.Tests/Advertising/TestAdvertisingSetDriver.cs +++ b/test/Darp.Ble.Tests/Advertising/TestAdvertisingSetDriver.cs @@ -5,10 +5,14 @@ namespace Darp.Ble.Tests.Advertising; /// An advertising set that records its calls; its broadcaster decides which ones fail -internal sealed class TestAdvertisingSetDriver(TestBroadcasterDriver broadcaster) : IAdvertisingSetDriver +internal sealed class TestAdvertisingSetDriver(TestBroadcasterDriver broadcaster, IAdvertisingSetSink sink) + : IAdvertisingSetDriver { private readonly TestBroadcasterDriver _broadcaster = broadcaster; + /// Where the set reports what the backend did on its own + public IAdvertisingSetSink Sink { get; } = sink; + public List Calls { get; } = []; /// When set, disposal waits until the test completes it diff --git a/test/Darp.Ble.Tests/Advertising/TestBroadcasterDriver.cs b/test/Darp.Ble.Tests/Advertising/TestBroadcasterDriver.cs index ec60b26d..c03dcf32 100644 --- a/test/Darp.Ble.Tests/Advertising/TestBroadcasterDriver.cs +++ b/test/Darp.Ble.Tests/Advertising/TestBroadcasterDriver.cs @@ -24,7 +24,7 @@ CancellationToken cancellationToken { if (HoldCreation is not null) await HoldCreation.Task.ConfigureAwait(false); - var set = new TestAdvertisingSetDriver(this); + var set = new TestAdvertisingSetDriver(this, sink); Sets.Add(set); return set; } diff --git a/test/Darp.Ble.Tests/Connections/RequestQueueTests.cs b/test/Darp.Ble.Tests/Connections/RequestQueueTests.cs index 3179930c..d0f5946b 100644 --- a/test/Darp.Ble.Tests/Connections/RequestQueueTests.cs +++ b/test/Darp.Ble.Tests/Connections/RequestQueueTests.cs @@ -1,6 +1,7 @@ using Darp.Ble.Connections; using Darp.Ble.Data; using Darp.Ble.Gatt; +using Darp.Ble.Testing; using Darp.Ble.Tests.Lifecycle; using Darp.Ble.Tests.Scanning; using Microsoft.Extensions.Time.Testing; @@ -8,7 +9,7 @@ namespace Darp.Ble.Tests.Connections; -/// How a connection's requests reach the driver, end and time out, through the public calls (seam 3) +/// How a connection's requests reach the driver, end and time out, through the public calls public sealed class RequestQueueTests { private static CancellationToken Token => TestContext.Current.CancellationToken; @@ -154,6 +155,114 @@ await Should.ThrowAsync(() => connection.DiscoverServi driver.DisposeCount.ShouldBe(1); } + [Fact] + public async Task ADriverThatLearnsOfTheLostLinkFromAFailedRequest_ReportsItFirst_AndTheCallerGetsADisconnect() + { + await using BleAdapter adapter = await OpenAsync(); + (ConnectedPeripheral connection, TestConnectionDriver driver) = await ConnectAsync(adapter); + Task> discovery = connection.DiscoverServicesAsync(Token); + PendingRequest request = await driver.NextRequestAsync(Token); + var info = new DisconnectInfo(DisconnectReason.ConnectionTimeout); + var failure = new BleException("The device cannot be reached"); + + driver.Sink.OnDisconnected(info); + request.Fail(failure); + + BleDisconnectedException exception = await Should.ThrowAsync(() => discovery); + exception.Info.ShouldBe(info); + exception.InnerException.ShouldBe(failure); + (await connection.Disconnected).ShouldBe(info); + } + + [Fact] + public async Task ARequestThatFailsBecauseTheLinkWasLost_SeesTheConnectionEndedAndGone() + { + await using BleAdapter adapter = await OpenAsync(); + (RemoteGattCharacteristic characteristic, TestConnectionDriver driver) = await DiscoverAsync(adapter); + ConnectedPeripheral connection = adapter.Central.Connections.Single(); + Task<(bool Disconnected, IReadOnlyList Open)> failed = ReadUntilDisconnectedAsync(); + await driver.NextRequestAsync(Token); + // The driver's cleanup still runs when the read fails + driver.HoldDispose = new TaskCompletionSource(); + + driver.Sink.OnDisconnected(new DisconnectInfo(DisconnectReason.ConnectionTimeout)); + + (bool disconnected, IReadOnlyList open) = await ReleasedAfter(failed, driver); + disconnected.ShouldBeTrue(); + open.ShouldBeEmpty(); + + async Task<(bool, IReadOnlyList)> ReadUntilDisconnectedAsync() + { + try + { + await characteristic.ReadAsync(Token); + } + catch (BleDisconnectedException) + { + return (connection.Disconnected.IsCompleted, adapter.Central.Connections); + } + throw new InvalidOperationException("The read did not fail"); + } + } + + [Fact] + public async Task AStreamThatEndsBecauseTheLinkWasLost_SeesTheConnectionEndedAndGone() + { + await using BleAdapter adapter = await OpenAsync(); + (RemoteGattCharacteristic characteristic, TestConnectionDriver driver) = await DiscoverAsync(adapter); + ConnectedPeripheral connection = adapter.Central.Connections.Single(); + using NotificationStream stream = characteristic.Subscribe(); + Task enable = characteristic.EnableNotificationsAsync(Token); + PendingRequest cccd = await driver.NextRequestAsync(Token); + cccd.Send(); + cccd.Answer(); + await enable; + Task<(bool Disconnected, IReadOnlyList Open)> ended = EnumerateUntilDisconnectedAsync(); + // The driver's cleanup still runs when the stream ends + driver.HoldDispose = new TaskCompletionSource(); + + driver.Sink.OnDisconnected(new DisconnectInfo(DisconnectReason.ConnectionTimeout)); + + (bool disconnected, IReadOnlyList open) = await ReleasedAfter(ended, driver); + disconnected.ShouldBeTrue(); + open.ShouldBeEmpty(); + + async Task<(bool, IReadOnlyList)> EnumerateUntilDisconnectedAsync() + { + try + { + await foreach (byte[] _ in stream.WithCancellation(Token)) { } + } + catch (BleDisconnectedException) + { + return (connection.Disconnected.IsCompleted, adapter.Central.Connections); + } + throw new InvalidOperationException("The stream did not fail"); + } + } + + [Fact] + public async Task AfterALostLink_DisposingTheConnectionAndItsAdapter_WaitForTheDriversCleanup() + { + BleAdapter adapter = await OpenAsync(); + (ConnectedPeripheral connection, TestConnectionDriver driver) = await ConnectAsync(adapter); + driver.HoldDispose = new TaskCompletionSource(); + driver.Sink.OnDisconnected(new DisconnectInfo(DisconnectReason.ConnectionTimeout)); + + Task disposingConnection = connection.DisposeAsync().AsTask(); + Task disposingAdapter = adapter.DisposeAsync().AsTask(); + + // Taken while the driver's cleanup still runs; asserted once it ended, so a failure cannot hang the test + bool connectionWaited = !disposingConnection.IsCompleted; + bool adapterWaited = !disposingAdapter.IsCompleted; + driver.HoldDispose.SetResult(); + await disposingConnection.WithWatchdog(); + await disposingAdapter.WithWatchdog(); + connectionWaited.ShouldBeTrue(); + adapterWaited.ShouldBeTrue(); + driver.DisposeCount.ShouldBe(1); + } + [Fact] public async Task AnAttErrorDuringDiscovery_ReachesTheCaller_AndTheConnectionStays() { @@ -338,6 +447,19 @@ public async Task ANotificationWhileEnabling_ReachesTheStreams() (await stream.FirstAsync(Token)).ShouldBe([7]); } + /// Waits for what the app saw while the driver's disposal is held, then lets the disposal go, also on a failure + private static async Task ReleasedAfter(Task seen, TestConnectionDriver driver) + { + try + { + return await seen.WithWatchdog(); + } + finally + { + driver.HoldDispose?.TrySetResult(); + } + } + private async Task<(RemoteGattCharacteristic Characteristic, TestConnectionDriver Driver)> DiscoverAsync( BleAdapter adapter ) @@ -349,6 +471,21 @@ BleAdapter adapter return ((await service.DiscoverCharacteristicsAsync(Token)).Single(), driver); } + [Fact] + public async Task ALargerMtuTheDriverReportsLater_LetsWritesCarryMore_AndASmallerOneIsIgnored() + { + await using BleAdapter adapter = await OpenAsync(); + (ConnectedPeripheral connection, TestConnectionDriver driver) = await ConnectAsync(adapter); + // The exchange agreed on 247 + connection.MaxWritePayloadLength.ShouldBe(244); + + // Windows exchanges the MTU itself, often after the connection was handed out + driver.Sink.OnMtuChanged(517); + driver.Sink.OnMtuChanged(23); + + connection.MaxWritePayloadLength.ShouldBe(514); + } + private async Task<(ConnectedPeripheral Connection, TestConnectionDriver Driver)> ConnectAsync(BleAdapter adapter) { ConnectedPeripheral connection = await adapter.Central.ConnectAsync( diff --git a/test/Darp.Ble.Tests/Darp.Ble.Tests.csproj b/test/Darp.Ble.Tests/Darp.Ble.Tests.csproj index 5ea03f8c..1b46b770 100644 --- a/test/Darp.Ble.Tests/Darp.Ble.Tests.csproj +++ b/test/Darp.Ble.Tests/Darp.Ble.Tests.csproj @@ -6,6 +6,8 @@ 13 false + + $(NoWarn);DARPBLE0001 diff --git a/test/Darp.Ble.Tests/Data/BleAddressTests.cs b/test/Darp.Ble.Tests/Data/BleAddressTests.cs index 253cc545..712d23dc 100644 --- a/test/Darp.Ble.Tests/Data/BleAddressTests.cs +++ b/test/Darp.Ble.Tests/Data/BleAddressTests.cs @@ -55,6 +55,11 @@ public void Parse_ValidString_ShouldBeSuccessful(string input, ulong expectedRaw [InlineData("00:11:22:33445500")] [InlineData("00:11:22:33:44550")] [InlineData("GG:HH:II:JJ:KK:LL")] + [InlineData("0/:00:00:00:00:00")] + [InlineData("@@:00:00:00:00:00")] + [InlineData("+1:00:00:00:00:00")] + [InlineData("AA:BB:CC:DD:EE:FFjunk")] + [InlineData("X:AA:BB:CC:DD:EE:FF")] public void Parse_InvalidString_ShouldNotBeSuccessful(string? input) { // Act @@ -107,6 +112,19 @@ public void ToString_Returns_ColonSeparatedUppercaseHex() s.ShouldBe("0A:1B:2C:3D:4E:5F"); } + [Theory] + [InlineData(BleAddressType.Public, "P:CC:F3:72:C1:1A:0A")] + [InlineData(BleAddressType.Random, "R:CC:F3:72:C1:1A:0A")] + public void FormatT_KeepsTheType_AndParseReadsItBack(BleAddressType type, string expected) + { + var address = new BleAddress(type, (UInt48)0xCCF372C11A0A); + + string text = $"{address:T}"; + + text.ShouldBe(expected); + BleAddress.Parse(text, provider: null).ShouldBe(address); + } + [Fact] public void Parse_ReadsToString_Back() { diff --git a/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsCastUserDataTests.cs b/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsCastUserDataTests.cs index 0389686a..9cc77ee9 100644 --- a/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsCastUserDataTests.cs +++ b/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsCastUserDataTests.cs @@ -1,3 +1,4 @@ +using Darp.Ble.Data.AssignedNumbers; using Darp.Ble.Gap; using Shouldly; @@ -11,10 +12,8 @@ private class Animal; private sealed class Dog : Animal, ICreature; - private const string ReportHex = "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB"; - private static GapAdvertisement CreateBaseAdvertisement() => - GapAdvertisement.FromExtendedAdvertisingReport(DateTimeOffset.UtcNow, ReportHex.ToByteArray()); + GapAdvertisementTests.Create(AdvertisingData.From([(AdTypes.Flags, [0x1A])])); [Fact] public async Task CastUserData_WhenAlreadyGeneric_ShouldPassThroughSameInstance() @@ -46,7 +45,7 @@ public async Task CastUserData_WhenUserDataMatchesButGenericDifferent_ShouldWrap // Assert result.UserData.ShouldBe(12345); - result.AsReadOnlyMemory().ShouldBe(advWithObject.AsReadOnlyMemory()); + result.Data.ShouldBe(advWithObject.Data); result.Address.ShouldBe(advWithObject.Address); result.EventType.ShouldBe(advWithObject.EventType); } @@ -129,7 +128,7 @@ public async Task CastUserData_DerivedMatchesBase_ShouldCastAndEmit() // Assert result.UserData.ShouldBeAssignableTo(); - result.AsReadOnlyMemory().ShouldBe(adv.AsReadOnlyMemory()); + result.Data.ShouldBe(adv.Data); } [Fact] diff --git a/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsOfUserDataTests.cs b/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsOfUserDataTests.cs index 9815f11f..5bf493b0 100644 --- a/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsOfUserDataTests.cs +++ b/test/Darp.Ble.Tests/Gap/AdvertisementStreamExtensionsOfUserDataTests.cs @@ -1,3 +1,4 @@ +using Darp.Ble.Data.AssignedNumbers; using Darp.Ble.Gap; using Shouldly; @@ -11,10 +12,8 @@ private class Animal; private sealed class Dog : Animal, ICreature; - private const string ReportHex = "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB"; - private static GapAdvertisement CreateBaseAdvertisement() => - GapAdvertisement.FromExtendedAdvertisingReport(DateTimeOffset.UtcNow, ReportHex.ToByteArray()); + GapAdvertisementTests.Create(AdvertisingData.From([(AdTypes.Flags, [0x1A])])); [Fact] public async Task OfUserData_WhenAlreadyGeneric_ShouldPassThroughSameInstance() @@ -40,7 +39,7 @@ public async Task OfUserData_WhenUserDataMatchesButGenericDifferent_ShouldWrapTo .FirstAsync(TestContext.Current.CancellationToken); result.UserData.ShouldBe(12345); - result.AsReadOnlyMemory().ShouldBe(advWithObject.AsReadOnlyMemory()); + result.Data.ShouldBe(advWithObject.Data); result.Address.ShouldBe(advWithObject.Address); result.EventType.ShouldBe(advWithObject.EventType); } @@ -70,7 +69,7 @@ public async Task OfUserData_DerivedMatchesBase_ShouldWrapAndEmit() result.ShouldNotBeNull(); result!.UserData.ShouldBeAssignableTo(); - result.AsReadOnlyMemory().ShouldBe(adv.AsReadOnlyMemory()); + result.Data.ShouldBe(adv.Data); } [Fact] diff --git a/test/Darp.Ble.Tests/Gap/AdvertisementStreamFilterTests.cs b/test/Darp.Ble.Tests/Gap/AdvertisementStreamFilterTests.cs index 9e74967e..de587c38 100644 --- a/test/Darp.Ble.Tests/Gap/AdvertisementStreamFilterTests.cs +++ b/test/Darp.Ble.Tests/Gap/AdvertisementStreamFilterTests.cs @@ -41,6 +41,18 @@ public async Task WhereAddress_KeepsAdvertisementsFromTheAddress() result.ShouldBe([HeartRateSensor, ScanResponse]); } + [Fact] + public async Task WhereAddress_WithAnAddressWithoutItsType_KeepsItsValueOfAnyType() + { + BleAddress parsed = BleAddress.Parse("00:00:00:00:00:01", provider: null); + + IGapAdvertisement[] result = await All.ToAsyncEnumerable() + .WhereAddress(parsed) + .ToArrayAsync(TestContext.Current.CancellationToken); + + result.ShouldBe([HeartRateSensor, ScanResponse]); + } + [Fact] public async Task EventFilters_KeepTheirEventType() { @@ -79,7 +91,7 @@ await Should.ThrowAsync(() => } private static GapAdvertisement Create(ulong address, BleEventType eventType, AdvertisingData data) => - GapAdvertisement.FromExtendedAdvertisingReport( + GapAdvertisement.Create( DateTimeOffset.UnixEpoch, eventType, new BleAddress(BleAddressType.Public, (UInt48)address), diff --git a/test/Darp.Ble.Tests/Gap/GapAdvertisementTests.cs b/test/Darp.Ble.Tests/Gap/GapAdvertisementTests.cs index b0bde8cd..c458365c 100644 --- a/test/Darp.Ble.Tests/Gap/GapAdvertisementTests.cs +++ b/test/Darp.Ble.Tests/Gap/GapAdvertisementTests.cs @@ -7,175 +7,68 @@ namespace Darp.Ble.Tests.Gap; public sealed class GapAdvertisementTests { - [Theory] - [InlineData( - BleEventType.AdvInd, - BleAddressType.Public, - 0xAABBCCDDEEFF, - Physical.Le1M, - Physical.NotAvailable, - AdvertisingSId.NoAdIProvided, - TxPowerLevel.NotAvailable, - -40, - PeriodicAdvertisingInterval.NoPeriodicAdvertising, - BleAddressType.NotAvailable, - 0x000000000000, - AdTypes.Flags, - "1A", - AdTypes.CompleteListOf16BitServiceOrServiceClassUuids, - "AABB", - "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB" - )] - [InlineData( - BleEventType.AdvNonConnInd, - BleAddressType.Random, - 0x5A1122334455, - Physical.Le1M, - Physical.NotAvailable, - AdvertisingSId.NoAdIProvided, - TxPowerLevel.NotAvailable, - -40, - PeriodicAdvertisingInterval.NoPeriodicAdvertising, - BleAddressType.NotAvailable, - 0x000000000000, - AdTypes.Flags, - "1A", - AdTypes.CompleteListOf16BitServiceOrServiceClassUuids, - "AABB", - "10000155443322115A0100FF7FD80000FF0000000000000702011A0303AABB" - )] - public void Advertisement_FromExtendedAdvertisingReport( - BleEventType eventType, - BleAddressType addressType, - ulong address, - Physical primaryPhy, - Physical secondaryPhy, - AdvertisingSId advertisingSId, - TxPowerLevel txPower, - sbyte rssi, - PeriodicAdvertisingInterval periodicAdvertisingInterval, - BleAddressType directAddressType, - ulong directAddress, - AdTypes advertisingDataType1, - string sectionDataHex1, - AdTypes advertisingDataType2, - string sectionDataHex2, - string expectedReportHex - ) - { - byte[] sectionData1 = Convert.FromHexString(sectionDataHex1); - byte[] sectionData2 = Convert.FromHexString(sectionDataHex2); - - GapAdvertisement adv = GapAdvertisement.FromExtendedAdvertisingReport( - DateTimeOffset.UtcNow, - eventType, - new BleAddress(addressType, (UInt48)address), - primaryPhy, - secondaryPhy, - advertisingSId, - txPower, - (Rssi)rssi, - periodicAdvertisingInterval, - new BleAddress(directAddressType, (UInt48)directAddress), - AdvertisingData.From([(advertisingDataType1, sectionData1), (advertisingDataType2, sectionData2)]) + private static readonly AdvertisingData FlagsAndServices = AdvertisingData.From([ + (AdTypes.Flags, [0x1A]), + (AdTypes.CompleteListOf16BitServiceOrServiceClassUuids, [0xBB, 0xAA]), + ]); + + internal static GapAdvertisement Create(AdvertisingData data, DateTimeOffset? timestamp = null) => + GapAdvertisement.Create( + timestamp ?? DateTimeOffset.UnixEpoch, + BleEventType.AdvInd, + new BleAddress(BleAddressType.Public, (UInt48)0xAABBCCDDEEFF), + Physical.Le1M, + Physical.NotAvailable, + AdvertisingSId.NoAdIProvided, + TxPowerLevel.NotAvailable, + (Rssi)(-40), + PeriodicAdvertisingInterval.NoPeriodicAdvertising, + BleAddress.NotAvailable, + data ); - string byteString = Convert.ToHexString(adv.AsReadOnlyMemory().Span); - - byteString.ShouldBe(expectedReportHex); - adv.EventType.ShouldBe(eventType); - adv.Address.Type.ShouldBe(addressType); - adv.Address.Value.ShouldBe((UInt48)address); - adv.PrimaryPhy.ShouldBe(primaryPhy); - adv.SecondaryPhy.ShouldBe(secondaryPhy); - adv.AdvertisingSId.ShouldBe(advertisingSId); - adv.TxPower.ShouldBe(txPower); - adv.Rssi.ShouldBe((Rssi)rssi); - adv.PeriodicAdvertisingInterval.ShouldBe(periodicAdvertisingInterval); - adv.DirectAddress.Type.ShouldBe(directAddressType); - adv.DirectAddress.Value.ShouldBe((UInt48)directAddress); - adv.Data.Count.ShouldBe(2); - (AdTypes Type, ReadOnlyMemory Bytes) dataSection1 = adv.Data[0]; - dataSection1.Type.ShouldBe(advertisingDataType1); - dataSection1.Bytes.ToArray().ShouldBe(sectionData1); - (AdTypes Type, ReadOnlyMemory Bytes) dataSection2 = adv.Data[1]; - dataSection2.Type.ShouldBe(advertisingDataType2); - dataSection2.Bytes.ToArray().ShouldBe(sectionData2); - } - - [Theory] - [InlineData(0x00, BleAddressType.Public)] - [InlineData(0x01, BleAddressType.Random)] - [InlineData(0x02, BleAddressType.PublicIdentity)] - [InlineData(0x03, BleAddressType.RandomStaticIdentity)] - [InlineData(0xFF, BleAddressType.NotAvailable)] - public void FromExtendedAdvertisingReport_WithHciAddressType_ShouldHaveAddressType( - byte hciAddressType, - BleAddressType expectedAddressType - ) - { - byte[] bytes = "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB".ToByteArray(); - bytes[2] = hciAddressType; - - GapAdvertisement adv = GapAdvertisement.FromExtendedAdvertisingReport(DateTimeOffset.UtcNow, bytes); - - adv.Address.Type.ShouldBe(expectedAddressType); - } - [Theory] - [InlineData("130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB")] - public void WithUserData_WithTestData_ShouldHaveEquivalentValues(string reportHex) + [Fact] + public void Create_KeepsDataLongerThanOneHciReport() { - const int testData = 12345; - GapAdvertisement adv = GapAdvertisement.FromExtendedAdvertisingReport( - DateTimeOffset.UtcNow, - reportHex.ToByteArray() - ); + // Ten sections of 31 bytes: 310 bytes, more than the 255 a single HCI report could describe + AdvertisingData data = AdvertisingData.From([ + .. Enumerable.Range(0, 10).Select(i => (AdTypes.ManufacturerSpecificData, new byte[29])), + ]); - IGapAdvertisement advWithData = adv.WithUserData(testData); + GapAdvertisement advertisement = Create(data); - advWithData.UserData.ShouldBe(testData); - (advWithData as IGapAdvertisementWithUserData)?.UserData.ShouldBe(testData); - advWithData.Timestamp.ShouldBe(adv.Timestamp); - advWithData.Address.ShouldBe(adv.Address); - advWithData.Data.ShouldBe(adv.Data); - advWithData.AsReadOnlyMemory().ShouldBe(adv.AsReadOnlyMemory()); + advertisement.Data.AsReadOnlyMemory().Length.ShouldBe(310); + advertisement.Data.Count.ShouldBe(10); } [Fact] - public void FromExtendedAdvertisingReport_CopiesTheReport() + public void EqualAdvertisements_HaveTheSameHashCode() { - byte[] report = "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB".ToByteArray(); - GapAdvertisement adv = GapAdvertisement.FromExtendedAdvertisingReport(DateTimeOffset.UtcNow, report); - - report.AsSpan().Clear(); + HashSet seen = [Create(FlagsAndServices)]; - Convert - .ToHexString(adv.AsReadOnlyMemory().Span) - .ShouldBe("130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB"); - adv.Address.Value.ShouldBe((UInt48)0xAABBCCDDEEFF); - adv.Data.Count.ShouldBe(2); + seen.Contains(Create(FlagsAndServices)).ShouldBeTrue(); } [Fact] - public void EqualAdvertisements_HaveTheSameHashCode() + public void AdvertisementsWithOtherData_AreNotEqual() { - byte[] report = "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB".ToByteArray(); - GapAdvertisement first = GapAdvertisement.FromExtendedAdvertisingReport(DateTimeOffset.UnixEpoch, report); - GapAdvertisement second = GapAdvertisement.FromExtendedAdvertisingReport(DateTimeOffset.UnixEpoch, report); - - HashSet seen = [first]; + GapAdvertisement other = Create(AdvertisingData.From([(AdTypes.Flags, [0x06])])); - seen.Contains(second).ShouldBeTrue(); + Create(FlagsAndServices).Equals(other).ShouldBeFalse(); } [Fact] - public void FromExtendedAdvertisingReport_DataIsTheEndOfTheReportBytes() + public void WithUserData_KeepsTheAdvertisementsValues() { - GapAdvertisement adv = GapAdvertisement.FromExtendedAdvertisingReport( - DateTimeOffset.UtcNow, - "130000FFEEDDCCBBAA0100FF7FD80000FF0000000000000702011A0303AABB".ToByteArray() - ); + GapAdvertisement advertisement = Create(FlagsAndServices); + + IGapAdvertisement withData = advertisement.WithUserData(12345); - adv.Data.AsReadOnlyMemory().ToArray().ShouldBe(adv.AsReadOnlyMemory()[24..].ToArray()); + withData.UserData.ShouldBe(12345); + (withData as IGapAdvertisementWithUserData)?.UserData.ShouldBe(12345); + withData.Timestamp.ShouldBe(advertisement.Timestamp); + withData.Address.ShouldBe(advertisement.Address); + withData.Rssi.ShouldBe(advertisement.Rssi); + withData.Data.ShouldBe(advertisement.Data); } } diff --git a/test/Darp.Ble.Tests/Mock/MockAirTests.cs b/test/Darp.Ble.Tests/Mock/MockAirTests.cs index 29d569f5..47972bc7 100644 --- a/test/Darp.Ble.Tests/Mock/MockAirTests.cs +++ b/test/Darp.Ble.Tests/Mock/MockAirTests.cs @@ -86,6 +86,29 @@ public async Task OnlyAnActiveScan_GetsTheScanResponseOfAScannableSet(ScanType s } } + [Fact] + public async Task AnExtendedSetWithMoreThan255Bytes_ArrivesWhole() + { + AdvertisingData longData = AdvertisingData.From([ + .. Enumerable.Range(0, 10).Select(i => (AdTypes.ManufacturerSpecificData, new byte[29])), + ]); + await using BleAdapter adapter = await OpenAsync(options => + options.AddPeripheral( + async (peripheral, _) => + await peripheral.Broadcaster.StartAdvertisingAsync(longData, Interval, BleEventType.None) + ) + ); + await using IAsyncEnumerator scan = adapter + .Observer.ScanAsync(Token) + .GetAsyncEnumerator(Token); + + ValueTask next = scan.MoveNextAsync(); + _time.Advance(Interval); + + (await next).ShouldBeTrue(); + scan.Current.Data.ToByteArray().ShouldBe(longData.ToByteArray()); + } + [Fact] public async Task ChangedAdvertisingData_ShowsUpInTheNextReport() { diff --git a/test/Darp.Ble.Tests/Mock/MockRangeTests.cs b/test/Darp.Ble.Tests/Mock/MockRangeTests.cs index 897b92e3..fe851a26 100644 --- a/test/Darp.Ble.Tests/Mock/MockRangeTests.cs +++ b/test/Darp.Ble.Tests/Mock/MockRangeTests.cs @@ -1,4 +1,5 @@ using Darp.Ble.Connections; +using Darp.Ble.Data; using Darp.Ble.Gap; using Darp.Ble.Mock; using Darp.Ble.Testing; @@ -40,6 +41,21 @@ public async Task Leave_EndsTheAppsConnectionToThePeripheral_WithConnectionTimeo (await central.Disconnected.WithWatchdog()).Reason.ShouldBe(DisconnectReason.ConnectionTimeout); } + [Fact] + public async Task AnAddressParsedFromText_ConnectsToThePeripheralWithThatValue() + { + await using BleAdapter adapter = await OpenAsync(new MockRange()); + (_, AdvertisingSet set) = await _sensor.Task; + BleAddress parsed = BleAddress.Parse(set.RandomAddress.ToString(), provider: null); + + await using ConnectedPeripheral connection = await adapter.Central.ConnectAsync( + parsed, + cancellationToken: Token + ); + + connection.Address.Value.ShouldBe(set.RandomAddress.Value); + } + [Fact] public async Task APeripheralOutOfRange_IsNeitherHeardNorReachable_UntilItEnters() { diff --git a/test/Darp.Ble.Tests/Scanning/TestAdvertisements.cs b/test/Darp.Ble.Tests/Scanning/TestAdvertisements.cs index 1e627960..85d9c03f 100644 --- a/test/Darp.Ble.Tests/Scanning/TestAdvertisements.cs +++ b/test/Darp.Ble.Tests/Scanning/TestAdvertisements.cs @@ -15,7 +15,7 @@ public static IGapAdvertisement Get(byte number) { if (!Advertisements.TryGetValue(number, out IGapAdvertisement? advertisement)) { - advertisement = GapAdvertisement.FromExtendedAdvertisingReport( + advertisement = GapAdvertisement.Create( DateTimeOffset.UnixEpoch, BleEventType.AdvNonConnInd, new BleAddress(BleAddressType.Public, (UInt48)number), diff --git a/test/Darp.Ble.WinRT.Hardware.Tests/Darp.Ble.WinRT.Hardware.Tests.csproj b/test/Darp.Ble.WinRT.Hardware.Tests/Darp.Ble.WinRT.Hardware.Tests.csproj new file mode 100644 index 00000000..9e58d55a --- /dev/null +++ b/test/Darp.Ble.WinRT.Hardware.Tests/Darp.Ble.WinRT.Hardware.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0-windows10.0.19041.0 + true + Exe + + false + 13 + + + + + + + + + + + + + diff --git a/test/Darp.Ble.WinRT.Hardware.Tests/HardwareTests.cs b/test/Darp.Ble.WinRT.Hardware.Tests/HardwareTests.cs new file mode 100644 index 00000000..d5a37f01 --- /dev/null +++ b/test/Darp.Ble.WinRT.Hardware.Tests/HardwareTests.cs @@ -0,0 +1,187 @@ +using Darp.Ble.Connections; +using Darp.Ble.Data; +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.Gap; +using Darp.Ble.Gatt; +using Darp.Ble.HciHost; +using Shouldly; + +namespace Darp.Ble.WinRT.Hardware.Tests; + +/// +/// Windows' radio against the nRF52840 dongle on the same machine, which runs HciHost. +/// Each test opens both adapters, so they run one after another +/// +[Collection(nameof(HardwareTests))] +[CollectionDefinition(nameof(HardwareTests), DisableParallelization = true)] +public sealed class HardwareTests +{ + private static readonly TimeSpan Patience = TimeSpan.FromSeconds(20); + private static readonly BleUuid ServiceUuid = BleUuid.Parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E", null); + private static readonly BleUuid ValueUuid = BleUuid.Parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E", null); + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static async Task OpenAsync(Action add) + { + IReadOnlyList adapters = await new BleManager(add).EnumerateAdaptersAsync(Token); + return await adapters.ShouldHaveSingleItem().OpenAsync(Token); + } + + private static Task OpenWindowsAsync() => OpenAsync(builder => builder.AddWinRT()); + + private static Task OpenDongleAsync() => OpenAsync(builder => builder.AddSerialHciHost()); + + private static AdvertisingData Marker(byte id) => + AdvertisingData.From([(AdTypes.ManufacturerSpecificData, new byte[] { 0xFF, 0xFF, 0xDA, 0x7B, id })]); + + private static async Task FindAsync(BleAdapter adapter, Func match) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token); + timeout.CancelAfter(Patience); + await foreach (IGapAdvertisement advertisement in adapter.Observer.ScanAsync(timeout.Token)) + { + if (match(advertisement)) + return advertisement; + } + throw new TimeoutException("Nothing matching was heard"); + } + + private static bool HasMarker(IGapAdvertisement advertisement, byte id) => + advertisement.Data.TryGetFirstType(AdTypes.ManufacturerSpecificData, out ReadOnlyMemory bytes) + && bytes.Span.SequenceEqual(new byte[] { 0xFF, 0xFF, 0xDA, 0x7B, id }); + + [Fact] + public async Task TheDefaultAdapter_Opens_WithItsRoles() + { + await using BleAdapter windows = await OpenWindowsAsync(); + + windows.Backend.ShouldBe(BleBackends.WinRT); + windows.Capabilities.HasFlag(Capabilities.Observer).ShouldBeTrue(); + TestContext.Current.SendDiagnosticMessage($"Capabilities: {windows.Capabilities}"); + } + + [Fact] + public async Task Windows_HearsTheDongleAdvertise() + { + await using BleAdapter windows = await OpenWindowsAsync(); + await using BleAdapter dongle = await OpenDongleAsync(); + await using AdvertisingSet set = await dongle.Broadcaster.StartAdvertisingAsync( + Marker(1), + TimeSpan.FromMilliseconds(100), + BleEventType.AdvNonConnInd, + cancellationToken: Token + ); + + IGapAdvertisement heard = await FindAsync(windows, advertisement => HasMarker(advertisement, 1)); + + heard.Address.Value.ShouldBe(set.RandomAddress.Value); + } + + [Fact] + public async Task TheDongle_HearsWindowsAdvertise() + { + await using BleAdapter windows = await OpenWindowsAsync(); + await using BleAdapter dongle = await OpenDongleAsync(); + await using AdvertisingSet set = await windows.Broadcaster.StartAdvertisingAsync( + Marker(2), + TimeSpan.FromMilliseconds(100), + BleEventType.AdvNonConnInd, + cancellationToken: Token + ); + + await FindAsync(dongle, advertisement => HasMarker(advertisement, 2)); + } + + [Fact] + public async Task Windows_UsesTheServicesTheDongleHosts() + { + await using BleAdapter windows = await OpenWindowsAsync(); + await using BleAdapter dongle = await OpenDongleAsync(); + byte[] stored = [42]; + var service = new LocalGattServiceBuilder(ServiceUuid); + LocalGattCharacteristic value = service.AddCharacteristic( + ValueUuid, + GattProperty.Read | GattProperty.Write | GattProperty.Notify, + onRead: (_, _) => ValueTask.FromResult(stored), + onWrite: (_, written, _) => + { + stored = written.ToArray(); + return ValueTask.FromResult(GattProtocolStatus.Success); + } + ); + await dongle.Peripheral.AddServiceAsync(service, Token); + await using AdvertisingSet set = await dongle.Broadcaster.StartAdvertisingAsync( + Marker(3), + TimeSpan.FromMilliseconds(100), + BleEventType.AdvInd, + cancellationToken: Token + ); + IGapAdvertisement heard = await FindAsync(windows, advertisement => HasMarker(advertisement, 3)); + + await using ConnectedPeripheral connection = await windows.Central.ConnectAsync( + heard.Address, + cancellationToken: Token + ); + RemoteGattCharacteristic remote = ( + await (await connection.DiscoverServiceAsync(ServiceUuid, Token)).DiscoverCharacteristicsAsync(Token) + ).Single(c => c.Uuid.Value == ValueUuid.Value); + byte[] read = await remote.ReadAsync(Token); + await remote.WriteAsync(new byte[] { 7 }, Token); + using NotificationStream notifications = remote.Subscribe(); + await remote.EnableNotificationsAsync(Token); + await value.NotifyAllAsync(new byte[] { 9 }, Token); + + read.ShouldBe([42]); + stored.ShouldBe([7]); + (await notifications.FirstAsync(Token).AsTask().WaitAsync(Patience, Token)).ShouldBe([9]); + } + + [Fact] + public async Task TheDongle_UsesTheServicesWindowsHosts() + { + await using BleAdapter windows = await OpenWindowsAsync(); + await using BleAdapter dongle = await OpenDongleAsync(); + byte[] stored = [42]; + var service = new LocalGattServiceBuilder(ServiceUuid); + LocalGattCharacteristic value = service.AddCharacteristic( + ValueUuid, + GattProperty.Read | GattProperty.Write | GattProperty.Notify, + onRead: (_, _) => ValueTask.FromResult(stored), + onWrite: (_, written, _) => + { + stored = written.ToArray(); + return ValueTask.FromResult(GattProtocolStatus.Success); + } + ); + await windows.Peripheral.AddServiceAsync(service, Token); + await using AdvertisingSet set = await windows.Broadcaster.StartAdvertisingAsync( + AdvertisingData.Empty, + TimeSpan.FromMilliseconds(100), + BleEventType.AdvInd, + cancellationToken: Token + ); + // Windows advertises the added service's UUID + IGapAdvertisement heard = await FindAsync( + dongle, + advertisement => advertisement.Data.GetServiceUuids().Any(uuid => uuid.Value == ServiceUuid.Value) + ); + + await using ConnectedPeripheral connection = await dongle.Central.ConnectAsync( + heard.Address, + cancellationToken: Token + ); + RemoteGattCharacteristic remote = ( + await (await connection.DiscoverServiceAsync(ServiceUuid, Token)).DiscoverCharacteristicsAsync(Token) + ).Single(c => c.Uuid.Value == ValueUuid.Value); + byte[] read = await remote.ReadAsync(Token); + await remote.WriteAsync(new byte[] { 7 }, Token); + using NotificationStream notifications = remote.Subscribe(); + await remote.EnableNotificationsAsync(Token); + await value.NotifyAllAsync(new byte[] { 9 }, Token); + + read.ShouldBe([42]); + stored.ShouldBe([7]); + (await notifications.FirstAsync(Token).AsTask().WaitAsync(Patience, Token)).ShouldBe([9]); + } +} diff --git a/test/Darp.Ble.WinRT.Hardware.Tests/RadioTests.cs b/test/Darp.Ble.WinRT.Hardware.Tests/RadioTests.cs new file mode 100644 index 00000000..d1978d19 --- /dev/null +++ b/test/Darp.Ble.WinRT.Hardware.Tests/RadioTests.cs @@ -0,0 +1,169 @@ +using Darp.Ble.Data; +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.Gap; +using Darp.Ble.Gatt; +using Shouldly; +using Windows.Devices.Radios; + +namespace Darp.Ble.WinRT.Hardware.Tests; + +/// +/// Windows' adapter while its Bluetooth radio turns off. The tests switch the machine's radio, which drops its other +/// Bluetooth devices, so they only run when asked for (--explicit only); they need no dongle +/// +[Collection(nameof(HardwareTests))] +public sealed class RadioTests +{ + private static readonly TimeSpan Patience = TimeSpan.FromSeconds(20); + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static async Task OpenWindowsAsync() + { + IReadOnlyList adapters = await new BleManager(builder => + builder.AddWinRT() + ).EnumerateAdaptersAsync(Token); + return await adapters.ShouldHaveSingleItem().OpenAsync(Token); + } + + private static async Task ScanToTheEndAsync(BleAdapter adapter) + { + await foreach (IGapAdvertisement _ in adapter.Observer.ScanAsync(Token)) { } + } + + [Fact(Explicit = true)] + public async Task Opening_WhileTheRadioIsOff_FailsAsDisabled_AndWorksOnceItIsOn() + { + await using RadioSwitch radio = await RadioSwitch.GetAsync(); + await radio.SetAsync(RadioState.Off); + + BleOpenException error = await Should.ThrowAsync(OpenWindowsAsync()); + await radio.SetAsync(RadioState.On); + + error.Kind.ShouldBe(BleOpenErrorKind.Disabled); + error.IsTransient.ShouldBeTrue(); + await using BleAdapter windows = await OpenWindowsAsync(); + } + + [Fact(Explicit = true)] + public async Task AScan_WhenTheRadioTurnsOff_FailsAsTransient() + { + await using RadioSwitch radio = await RadioSwitch.GetAsync(); + await using BleAdapter windows = await OpenWindowsAsync(); + Task scan = ScanToTheEndAsync(windows); + await Task.Delay(TimeSpan.FromSeconds(1), Token); + + await radio.SetAsync(RadioState.Off); + + BleException error = await Should.ThrowAsync(scan.WaitAsync(Patience, Token)); + error.IsTransient.ShouldBeTrue(); + } + + [Fact(Explicit = true)] + public async Task StartingAScan_WhileTheRadioIsOff_FailsAsTransient() + { + await using RadioSwitch radio = await RadioSwitch.GetAsync(); + await using BleAdapter windows = await OpenWindowsAsync(); + await radio.SetAsync(RadioState.Off); + + BleException error = await Should.ThrowAsync( + ScanToTheEndAsync(windows).WaitAsync(Patience, Token) + ); + + error.IsTransient.ShouldBeTrue(); + } + + [Fact(Explicit = true)] + public async Task AnAdvertisingSet_StoppedByTheRadio_StartsAgainOnceTheRadioIsOn() + { + await using RadioSwitch radio = await RadioSwitch.GetAsync(); + await using BleAdapter windows = await OpenWindowsAsync(); + await using AdvertisingSet set = await windows.Broadcaster.StartAdvertisingAsync( + AdvertisingData.From([(AdTypes.ManufacturerSpecificData, new byte[] { 0xFF, 0xFF, 0xDA, 0x7B, 1 })]), + TimeSpan.FromMilliseconds(100), + BleEventType.AdvNonConnInd, + cancellationToken: Token + ); + + await radio.SetAsync(RadioState.Off); + // The set knows it stopped, so starting it really starts the publisher, which fails while the radio is off + BleException whileOff = await Should.ThrowAsync(set.StartAsync(Token).WaitAsync(Patience, Token)); + await radio.SetAsync(RadioState.On); + + whileOff.IsTransient.ShouldBeTrue(); + await StartOnceTheRadioIsBackAsync(set); + } + + /// Windows reports the radio on about a second before it advertises again, so a transient failure is retried, as an app would + private static async Task StartOnceTheRadioIsBackAsync(AdvertisingSet set) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token); + timeout.CancelAfter(Patience); + while (true) + { + try + { + await set.StartAsync(timeout.Token); + return; + } + catch (BleException e) when (e.IsTransient) + { + await Task.Delay(TimeSpan.FromMilliseconds(500), timeout.Token); + } + } + } + + [Fact(Explicit = true)] + public async Task StartingAConnectableSet_WhileTheRadioIsOff_FailsAsTransient() + { + await using RadioSwitch radio = await RadioSwitch.GetAsync(); + await using BleAdapter windows = await OpenWindowsAsync(); + var service = new LocalGattServiceBuilder(BleUuid.Parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E", null)); + service.AddCharacteristic( + BleUuid.Parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E", null), + GattProperty.Read, + onRead: (_, _) => ValueTask.FromResult(new byte[] { 42 }) + ); + await windows.Peripheral.AddServiceAsync(service, Token); + await radio.SetAsync(RadioState.Off); + + BleException error = await Should.ThrowAsync( + windows + .Broadcaster.StartAdvertisingAsync( + AdvertisingData.Empty, + TimeSpan.FromMilliseconds(100), + BleEventType.AdvInd, + cancellationToken: Token + ) + .WaitAsync(Patience, Token) + ); + + error.IsTransient.ShouldBeTrue(); + } + + /// Switches the Bluetooth radio within one test, and always back on at its end + private sealed class RadioSwitch : IAsyncDisposable + { + private readonly Radio _radio; + + private RadioSwitch(Radio radio) => _radio = radio; + + public static async Task GetAsync() + { + (await Radio.RequestAccessAsync()).ShouldBe(RadioAccessStatus.Allowed); + IReadOnlyList radios = await Radio.GetRadiosAsync(); + return new RadioSwitch(radios.Single(radio => radio.Kind is RadioKind.Bluetooth)); + } + + public async Task SetAsync(RadioState state) + { + (await _radio.SetStateAsync(state)).ShouldBe(RadioAccessStatus.Allowed); + _radio.State.ShouldBe(state); + // Windows reports the radio on about a second before it advertises again; the next test starts after that + if (state is RadioState.On) + await Task.Delay(TimeSpan.FromSeconds(2)); + } + + public async ValueTask DisposeAsync() => await SetAsync(RadioState.On); + } +} diff --git a/test/Darp.Ble.WinRT.Tests/AttributeWrappersTests.cs b/test/Darp.Ble.WinRT.Tests/AttributeWrappersTests.cs new file mode 100644 index 00000000..5b307250 --- /dev/null +++ b/test/Darp.Ble.WinRT.Tests/AttributeWrappersTests.cs @@ -0,0 +1,51 @@ +using Darp.Ble.WinRT.Connections; +using Shouldly; + +namespace Darp.Ble.WinRT.Tests; + +/// Every native GATT object of a connection has one owner, also when a discovery completes after the end +public sealed class AttributeWrappersTests +{ + private readonly List _released = []; + private readonly AttributeWrappers _wrappers; + + public AttributeWrappersTests() => + _wrappers = new AttributeWrappers(n => n.Handle, n => new Wrapper(n), _released.Add); + + [Fact] + public void ARediscoveredAttribute_GetsItsFirstWrapper_AndNothingIsReleased() + { + var first = new Native(1); + var again = new Native(1); + Wrapper wrapper = _wrappers.Wrap([first]).ShouldNotBeNull().ShouldHaveSingleItem(); + + _wrappers.Wrap([again]).ShouldNotBeNull().ShouldHaveSingleItem().ShouldBeSameAs(wrapper); + + // Windows may return the wrapped object again; releasing it would break the wrapper + _released.ShouldBeEmpty(); + } + + [Fact] + public void ADiscoveryThatCompletesAfterTheEnd_WrapsNothing_AndReleasesItsNativeObjects() + { + Wrapper early = _wrappers.Wrap([new Native(1)]).ShouldNotBeNull().ShouldHaveSingleItem(); + + _wrappers.End().ShouldBe([early]); + var late = new Native(2); + var known = new Native(1); + + _wrappers.Wrap([late, known]).ShouldBeNull(); + _released.ShouldBe([late, known]); + } + + // Classes, not records: the tests tell apart two native objects of the same handle + private sealed class Native(ushort handle) + { + public ushort Handle { get; } = handle; + } + + private sealed class Wrapper(Native native) + { + public Native Native { get; } = native; + } +} diff --git a/test/Darp.Ble.WinRT.Tests/CentralSessionsTests.cs b/test/Darp.Ble.WinRT.Tests/CentralSessionsTests.cs new file mode 100644 index 00000000..37f93e77 --- /dev/null +++ b/test/Darp.Ble.WinRT.Tests/CentralSessionsTests.cs @@ -0,0 +1,69 @@ +using Darp.Ble.WinRT.Connections; +using Shouldly; + +namespace Darp.Ble.WinRT.Tests; + +/// One central per device for the connection's lifetime, across Windows' sessions +public sealed class CentralSessionsTests +{ + private readonly CentralSessions _sessions = new(); + private int _created; + + private string Create() => $"central {++_created}"; + + [Fact] + public void ADevicesSessions_ShareOneCentral_AndEachIsNewOnce() + { + object first = new(); + object second = new(); + + _sessions.Resolve("device", first, Create).ShouldBe(("central 1", true, true)); + _sessions.Resolve("device", second, Create).ShouldBe(("central 1", false, true)); + _sessions.Resolve("device", second, Create).ShouldBe(("central 1", false, false)); + } + + [Fact] + public void TheCentralEnds_OnceItsLastSessionClosed_AndTheNextActivityGetsANewOne() + { + object first = new(); + object second = new(); + _sessions.Resolve("device", first, Create); + _sessions.Resolve("device", second, Create); + + _sessions.Close(first).ShouldBeNull(); + _sessions.Close(second).ShouldBe("central 1"); + + _sessions.Resolve("device", new object(), Create).ShouldBe(("central 2", true, true)); + } + + [Fact] + public void ACallbackOfAClosedSession_IsIgnored() + { + object session = new(); + _sessions.Resolve("device", session, Create); + _sessions.Close(session); + + _sessions.Resolve("device", session, Create).ShouldBe((null, false, false)); + } + + [Fact] + public void EndingACentral_IgnoresTheLateCallbacksOfItsSessions() + { + object session = new(); + string central = _sessions.Resolve("device", session, Create).Central.ShouldNotBeNull(); + + _sessions.End(central).ShouldBeTrue(); + + _sessions.Resolve("device", session, Create).ShouldBe((null, false, false)); + _sessions.Close(session).ShouldBeNull(); + _sessions.Resolve("device", new object(), Create).ShouldBe(("central 2", true, true)); + } + + [Fact] + public void Devices_AreTrackedApart() + { + _sessions.Resolve("device", new object(), Create); + + _sessions.Resolve("other", new object(), Create).ShouldBe(("central 2", true, true)); + } +} diff --git a/test/Darp.Ble.WinRT.Tests/Darp.Ble.WinRT.Tests.csproj b/test/Darp.Ble.WinRT.Tests/Darp.Ble.WinRT.Tests.csproj new file mode 100644 index 00000000..fbe0f34c --- /dev/null +++ b/test/Darp.Ble.WinRT.Tests/Darp.Ble.WinRT.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + Exe + true + 13 + + + + all + + + + + + + + + + + diff --git a/test/Darp.Ble.WinRT.Tests/PublisherSectionsTests.cs b/test/Darp.Ble.WinRT.Tests/PublisherSectionsTests.cs new file mode 100644 index 00000000..96be26f1 --- /dev/null +++ b/test/Darp.Ble.WinRT.Tests/PublisherSectionsTests.cs @@ -0,0 +1,22 @@ +using Darp.Ble.Data.AssignedNumbers; +using Darp.Ble.Gap; +using Darp.Ble.WinRT.Gap; +using Shouldly; + +namespace Darp.Ble.WinRT.Tests; + +/// The data sections Windows' publisher takes from the app +public sealed class PublisherSectionsTests +{ + [Fact] + public void SectionsWindowsSetsItself_AreLeftOut_AndNamed() + { + AdvertisingData data = AdvertisingData.From(Convert.FromHexString("020106" + "05FF4C001234" + "0409446172")); + + (IReadOnlyList<(AdTypes Type, ReadOnlyMemory Bytes)> kept, IReadOnlyList dropped) = + PublisherSections.Split(data); + + kept.Select(section => section.Type).ShouldBe([AdTypes.ManufacturerSpecificData]); + dropped.ShouldBe([AdTypes.Flags, AdTypes.CompleteLocalName]); + } +} diff --git a/test/Darp.Ble.WinRT.Tests/SubscriptionSnapshotsTests.cs b/test/Darp.Ble.WinRT.Tests/SubscriptionSnapshotsTests.cs new file mode 100644 index 00000000..14c525f2 --- /dev/null +++ b/test/Darp.Ble.WinRT.Tests/SubscriptionSnapshotsTests.cs @@ -0,0 +1,57 @@ +using Darp.Ble.WinRT.Gatt; +using Shouldly; + +namespace Darp.Ble.WinRT.Tests; + +/// Turning Windows' lists of subscribed clients into subscription changes +public sealed class SubscriptionSnapshotsTests +{ + private readonly SubscriptionSnapshots _snapshots = new(); + + private (string[] Added, string[] Removed) Update(string characteristic, string[] subscribed) + { + (IReadOnlyList added, IReadOnlyList removed) = _snapshots.Update(characteristic, subscribed); + return ([.. added], [.. removed]); + } + + [Fact] + public void ANewSnapshot_ReportsWhoSubscribedAndWhoLeft() + { + Update("level", ["a", "b"]) + .ShouldSatisfyAllConditions(r => r.Added.ShouldBe(["a", "b"]), r => r.Removed.ShouldBe([])); + + Update("level", ["b", "c"]) + .ShouldSatisfyAllConditions(r => r.Added.ShouldBe(["c"]), r => r.Removed.ShouldBe(["a"])); + _snapshots.IsSubscribed("level", "b").ShouldBeTrue(); + _snapshots.IsSubscribed("level", "a").ShouldBeFalse(); + } + + [Fact] + public void TheSameSnapshotAgain_ReportsNothing() + { + _snapshots.Update("level", ["a"]); + + Update("level", ["a"]).ShouldSatisfyAllConditions(r => r.Added.ShouldBe([]), r => r.Removed.ShouldBe([])); + } + + [Fact] + public void Characteristics_AreTrackedApart() + { + _snapshots.Update("level", ["a"]); + + Update("other", ["a"]).ShouldSatisfyAllConditions(r => r.Added.ShouldBe(["a"]), r => r.Removed.ShouldBe([])); + Update("other", []).ShouldSatisfyAllConditions(r => r.Added.ShouldBe([]), r => r.Removed.ShouldBe(["a"])); + _snapshots.IsSubscribed("level", "a").ShouldBeTrue(); + } + + [Fact] + public void AClientWhoseConnectionEnded_IsForgotten_AndCountsAsNewWhenItSubscribesAgain() + { + _snapshots.Update("level", ["a"]); + + _snapshots.Forget("a"); + + _snapshots.IsSubscribed("level", "a").ShouldBeFalse(); + Update("level", ["a"]).ShouldSatisfyAllConditions(r => r.Added.ShouldBe(["a"]), r => r.Removed.ShouldBe([])); + } +}