Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Darp.Ble.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
<Project Path="src/Darp.Ble.Mock/Darp.Ble.Mock.csproj" />
<Project Path="src/Darp.Ble/Darp.Ble.csproj" />
<Project Path="src/Darp.Ble.SerilogTracing/Darp.Ble.SerilogTracing.csproj" />
<Project Path="src/Darp.Ble.WinRT/Darp.Ble.WinRT.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/Darp.Ble.Hci.Tests/Darp.Ble.Hci.Tests.csproj" />
<Project Path="test/Darp.Ble.HciHost.Tests/Darp.Ble.HciHost.Tests.csproj" />
<Project Path="test/Darp.Ble.Tests/Darp.Ble.Tests.csproj" />
<Project Path="test/Darp.Ble.WinRT.Hardware.Tests/Darp.Ble.WinRT.Hardware.Tests.csproj" />
<Project Path="test/Darp.Ble.WinRT.Tests/Darp.Ble.WinRT.Tests.csproj" />
<Project Path="test\Darp.Ble.HciHost.Verify\Darp.Ble.HciHost.Verify.csproj" />
</Folder>
<Folder Name="/tools/">
Expand Down
13 changes: 12 additions & 1 deletion docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ metrics.AddMeter(BleTelemetry.MeterName); // "Darp.Ble"

`BleManagerBuilder.ConfigureDiagnostics(options => options.IncludeAddresses = false)` replaces peer addresses with `<hidden>` 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`)

Expand Down Expand Up @@ -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} |
Expand Down Expand Up @@ -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 |
10 changes: 7 additions & 3 deletions src/Darp.Ble.HciHost/Connections/ConnectAttempt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion src/Darp.Ble.HciHost/Connections/HciHostCentralDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -34,6 +36,19 @@ public async Task<IConnectionDriver> 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)
Expand Down
65 changes: 65 additions & 0 deletions src/Darp.Ble.HciHost/Gap/AdvertisingReportAssembler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Darp.Ble.Hci.Payload.Event;

namespace Darp.Ble.HciHost.Gap;

/// <summary>
/// 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
/// </summary>
/// <remarks> Only the transport's read loop uses it </remarks>
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<Sender, Pending> _pending = [];

/// <summary> The data of the whole advertisement, once a report ends it </summary>
/// <param name="report"> The next report </param>
/// <param name="data"> The data of the advertisement this report ends </param>
/// <param name="txPower"> Its TX power: later reports may leave out the one an earlier report gave </param>
/// <returns> False while more data is to come, or when an advertisement is longer than any can be </returns>
public bool TryEnd(HciLeExtendedAdvertisingReport report, out ReadOnlyMemory<byte> 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;
}

/// <summary> A new scan starts without the parts of the last one </summary>
public void Clear() => _pending.Clear();

private readonly record struct Sender(byte AddressType, ulong Address, byte AdvertisingSId, bool IsScanResponse);

/// <summary> The data so far, or null for an advertisement too long to deliver </summary>
private readonly record struct Pending(byte[]? Data, sbyte TxPower);
}
48 changes: 44 additions & 4 deletions src/Darp.Ble.HciHost/Gap/HciHostObserverDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ulong, BleAddressType> _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;
Expand Down Expand Up @@ -85,33 +94,64 @@ await _device
.ConfigureAwait(false);
}

/// <summary> The type the address was last heard with, if it was heard </summary>
/// <param name="address"> The value of the address </param>
/// <returns> The address type, or null </returns>
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<byte> 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)
)
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Darp.Ble.HciHost/HciHostAdapterDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/Darp.Ble.Mock/Gap/MockObserverDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/Darp.Ble.Mock/MockAir.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
Expand Down
18 changes: 0 additions & 18 deletions src/Darp.Ble.WinRT/BleHelper.cs

This file was deleted.

68 changes: 68 additions & 0 deletions src/Darp.Ble.WinRT/Connections/AttributeWrappers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace Darp.Ble.WinRT.Connections;

/// <summary>
/// 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
/// </summary>
internal sealed class AttributeWrappers<TNative, TWrapper>
where TWrapper : class
{
private readonly Func<TNative, ushort> _handleOf;
private readonly Func<TNative, TWrapper> _wrap;
private readonly Action<TNative> _release;
private readonly Lock _lock = new();
private readonly Dictionary<ushort, TWrapper> _byHandle = [];
private bool _ended;

/// <summary> Starts without wrappers </summary>
/// <param name="handleOf"> The attribute handle of a native object </param>
/// <param name="wrap"> Wraps a native object the first time its handle shows up </param>
/// <param name="release"> Releases a native object of a discovery that completed after the end </param>
public AttributeWrappers(Func<TNative, ushort> handleOf, Func<TNative, TWrapper> wrap, Action<TNative> release)
{
_handleOf = handleOf;
_wrap = wrap;
_release = release;
}

/// <summary> The wrappers of what a discovery returned; null once the connection ended </summary>
public IReadOnlyList<TWrapper>? Wrap(IReadOnlyList<TNative> 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;
}
}

/// <summary> The connection ended; returns every wrapper handed out, for the caller to release </summary>
public IReadOnlyList<TWrapper> End()
{
lock (_lock)
{
_ended = true;
return [.. _byHandle.Values];
}
}
}
Loading