share BTSession's address privacy implementation with BLESession

This commit is contained in:
Christopher Willis-Ford 2022-07-19 11:56:53 -07:00
parent 0b0b8802a8
commit 7ec18de421
8 changed files with 152 additions and 119 deletions

View file

@ -10,7 +10,9 @@ using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using CoreBluetooth;
using Fleck;
using Foundation;
using Microsoft.Extensions.DependencyInjection;
using ScratchLink.Extensions;
using ScratchLink.JsonRpc;
@ -18,12 +20,15 @@ using ScratchLink.JsonRpc;
/// <summary>
/// Implements the cross-platform portions of a BLE session.
/// </summary>
/// <typeparam name="TUUID">The platform-specific type which represents UUIDs (like Guid or CBUUID).</typeparam>
internal abstract class BLESession<TUUID> : Session
/// <typeparam name="TPeripheral">The platform-specific type for a BLE peripheral device.</typeparam>
/// <typeparam name="TPeripheralAddress">The platform-specific type for a BLE peripheral device's address.</typeparam>
/// <typeparam name="TUUID">The platform-specific type which represents BLE UUIDs (like Guid or CBUUID).</typeparam>
internal abstract class BLESession<TPeripheral, TPeripheralAddress, TUUID> : PeripheralSession<TPeripheral, TPeripheralAddress>
where TPeripheral : class
where TUUID : IEquatable<TUUID>
{
/// <summary>
/// Initializes a new instance of the <see cref="BLESession{TUUID}"/> class.
/// Initializes a new instance of the <see cref="BLESession{TDevice, TPeripheralAddress, TUUID}"/> class.
/// </summary>
/// <inheritdoc cref="Session.Session(IWebSocketConnection)"/>
public BLESession(IWebSocketConnection webSocket)
@ -32,7 +37,6 @@ internal abstract class BLESession<TUUID> : Session
this.GattHelpers = ScratchLinkApp.Current.Services.GetService<GattHelpers<TUUID>>();
this.AllowedServices = new ();
this.Handlers["discover"] = this.HandleDiscover;
this.Handlers["connect"] = this.HandleConnect;
this.Handlers["write"] = this.HandleWrite;
this.Handlers["read"] = this.HandleRead;
this.Handlers["startNotifications"] = this.HandleStartNotifications;
@ -147,6 +151,7 @@ internal abstract class BLESession<TUUID> : Session
this.AllowedServices.UnionWith(filter.RequiredServices.OrEmpty());
}
this.ClearPeripherals();
return await this.DoDiscover(filters);
}
@ -158,31 +163,26 @@ internal abstract class BLESession<TUUID> : Session
protected abstract Task<object> DoDiscover(List<BLEScanFilter> filters);
/// <summary>
/// Implement the JSON-RPC "connect" request to connect to a particular peripheral.
/// Valid in the discovery state; transitions to connected state on success.
/// Track a discovered peripheral device and report it to the client.
/// </summary>
/// <param name="methodName">The name of the method being called ("connect").</param>
/// <param name="args">
/// A JSON object containing the UUID of a peripheral found by the most recent discovery request.
/// </param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected async Task<object> HandleConnect(string methodName, JsonElement? args)
/// <param name="peripheral">The platform-specific device reference or record.</param>
/// <param name="peripheralAddress">The internal system address of this device.</param>
/// <param name="displayName">A user-friendly name, if possible.</param>
/// <param name="rssi">A relative signal strength indicator.</param>
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
protected async Task OnPeripheralDiscovered(TPeripheral peripheral, TPeripheralAddress peripheralAddress, string displayName, int rssi)
{
if (args?.TryGetProperty("peripheralId", out var jsonPeripheralId) != true)
var peripheralId = this.RegisterPeripheral(peripheral, peripheralAddress);
var message = new BLEPeripheralDiscovered()
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("connect request must include peripheralId"));
}
return await this.DoConnect(jsonPeripheralId);
Name = displayName,
PeripheralId = peripheralId,
RSSI = rssi,
};
await this.SendNotification("didDiscoverPeripheral", message, this.CancellationToken);
}
/// <summary>
/// Platform-specific implementation for connecting to a peripheral device.
/// </summary>
/// <param name="jsonPeripheralId">A JSON element representing a platform-specific peripheral ID.</param>
/// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns>
protected abstract Task<object> DoConnect(JsonElement jsonPeripheralId);
/// <summary>
/// Implement the JSON-RPC "write" request to write a value to a particular service characteristic.
/// </summary>

View file

@ -16,18 +16,15 @@ using ScratchLink.JsonRpc;
/// <summary>
/// Implements the cross-platform portions of a Bluetooth Classic (RFCOMM) session.
/// </summary>
/// <typeparam name="TDevice">Platform-specific device reference. Used to make a device connection.</typeparam>
/// <typeparam name="TDeviceId">Platform-specific device address. Used for tracking device discovery records.</typeparam>
internal abstract class BTSession<TDevice, TDeviceId> : Session
/// <inheritdoc cref="PeripheralSession{TPeripheral, TPeripheralAddress}"/>
internal abstract class BTSession<TPeripheral, TPeripheralAddress> : PeripheralSession<TPeripheral, TPeripheralAddress>
where TPeripheral : class
{
/// <summary>
/// PIN code for auto-pairing.
/// </summary>
protected const string AutoPairingCode = "0000";
private readonly Dictionary<TDeviceId, string> deviceIdToString = new ();
private readonly Dictionary<string, TDevice> availableDevices = new ();
/// <summary>
/// Initializes a new instance of the <see cref="BTSession{TDevice, TDeviceId}"/> class.
/// </summary>
@ -59,7 +56,7 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("majorDeviceClass and minorDeviceClass required"));
}
this.availableDevices.Clear();
this.ClearPeripherals();
return this.DoDiscover((byte)majorDeviceClass, (byte)minorDeviceClass);
}
@ -71,39 +68,6 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected abstract Task<object> DoDiscover(byte majorDeviceClass, byte minorDeviceClass);
/// <summary>
/// Implement the JSON-RPC "connect" request to connect to a particular peripheral.
/// Valid in the discovery state; transitions to connected state on success.
/// </summary>
/// <param name="methodName">The name of the method being called ("connect").</param>
/// <param name="args">
/// A JSON object containing the ID of a peripheral found by the most recent discovery request.
/// </param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected Task<object> HandleConnect(string methodName, JsonElement? args)
{
var peripheralId = args?.TryGetProperty("peripheralId")?.GetString();
if (peripheralId == null)
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("peripheralId required"));
}
if (!this.availableDevices.TryGetValue(peripheralId, out var device))
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidRequest(string.Format("Device {0} not available for connection", peripheralId)));
}
return this.DoConnect(device);
}
/// <summary>
/// Platform-specific implementation for connecting to a peripheral device.
/// </summary>
/// <param name="device">The requested device.</param>
/// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns>
protected abstract Task<object> DoConnect(TDevice device);
/// <summary>
/// Implement the JSON-RPC "send" request to send data to the connected peripheral.
/// </summary>
@ -148,15 +112,14 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
/// <summary>
/// Track a discovered device and report it to the client.
/// </summary>
/// <param name="device">The platform-specific device reference or record.</param>
/// <param name="deviceId">The internal system address of this device.</param>
/// <param name="peripheral">The platform-specific device reference or record.</param>
/// <param name="peripheralAddress">The internal system address of this device.</param>
/// <param name="displayName">A user-friendly name, if possible.</param>
/// <param name="rssi">A relative signal strength indicator.</param>
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
protected async Task OnDeviceFound(TDevice device, TDeviceId deviceId, string displayName, int rssi)
protected async Task OnPeripheralDiscovered(TPeripheral peripheral, TPeripheralAddress peripheralAddress, string displayName, int rssi)
{
var peripheralId = this.GetPeripheralId(deviceId);
this.availableDevices[peripheralId] = device;
var peripheralId = this.RegisterPeripheral(peripheral, peripheralAddress);
var message = new BTPeripheralDiscovered
{
@ -167,17 +130,6 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
await this.SendRequest("didDiscoverPeripheral", message, this.CancellationToken);
}
private string GetPeripheralId(TDeviceId deviceId)
{
if (!this.deviceIdToString.TryGetValue(deviceId, out var peripheralId))
{
peripheralId = Guid.NewGuid().ToString();
this.deviceIdToString[deviceId] = peripheralId;
}
return peripheralId;
}
/// <summary>
/// JSON-ready class to use when reporting that a peripheral was discovered.
/// </summary>

View file

@ -9,7 +9,7 @@ using System.Text.Json.Serialization;
/// <summary>
/// Data class representing a JSON-RPC 2.0 Error object.
/// </summary>
internal class JsonRpc2Error
public class JsonRpc2Error
{
/// <summary>
/// Gets or sets the numeric error code for this error.

View file

@ -0,0 +1,111 @@
// <copyright file="PeripheralSession.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink;
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Fleck;
using ScratchLink.Extensions;
using ScratchLink.JsonRpc;
/// <summary>
/// A kind of session which discovers and connects to peripheral devices by some sort of address.
/// One session can search for, connect to, and interact with one peripheral device.
/// Handles address privacy.
/// </summary>
/// <typeparam name="TPeripheral">The type of peripheral device handled by this session.</typeparam>
/// <typeparam name="TPeripheralAddress">The type of address (UUID, path, etc.) used by this session to identify a peripheral device.</typeparam>
public abstract class PeripheralSession<TPeripheral, TPeripheralAddress> : Session
where TPeripheral : class
{
private readonly Dictionary<TPeripheralAddress, string> peripheralAddressToId = new ();
private readonly Dictionary<string, TPeripheral> discoveredPeripherals = new ();
/// <summary>
/// Initializes a new instance of the <see cref="PeripheralSession{TPeripheral, TPeripheralAddress}"/> class.
/// </summary>
/// <param name="webSocket">The WebSocket which this session will use for communication.</param>
public PeripheralSession(IWebSocketConnection webSocket)
: base(webSocket)
{
this.Handlers["connect"] = this.HandleConnect;
}
/// <summary>
/// Implement the JSON-RPC "connect" request to connect to a particular peripheral device.
/// Valid in the discovery state; transitions to connected state on success.
/// </summary>
/// <param name="methodName">The name of the method being called ("connect").</param>
/// <param name="args">
/// A JSON object containing the ID of a peripheral found by the most recent discovery request.
/// </param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected async Task<object> HandleConnect(string methodName, JsonElement? args)
{
var peripheralId = args?.TryGetProperty("peripheralId")?.GetString();
if (peripheralId == null)
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("connect request must include peripheralId"));
}
var peripheral = this.GetPeripheral(peripheralId);
if (peripheral == null)
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidRequest(string.Format("peripheral {0} not available for connection", peripheralId)));
}
return await this.DoConnect(peripheral);
}
/// <summary>
/// Platform-specific implementation for connecting to a peripheral device.
/// </summary>
/// <param name="peripheral">The requested peripheral device.</param>
/// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns>
protected abstract Task<object> DoConnect(TPeripheral peripheral);
/// <summary>
/// Store the peripheral in the "discovered peripherals" list using a session-specific peripheral ID.
/// Storing a peripheral with the same address several times during the same session will result in the same ID each time.
/// </summary>
/// <param name="peripheral">The peripheral being registered.</param>
/// <param name="peripheralAddress">The peripheral device's address.</param>
/// <returns>An anonymized, session-specific peripheral ID.</returns>
protected string RegisterPeripheral(TPeripheral peripheral, TPeripheralAddress peripheralAddress)
{
if (!this.peripheralAddressToId.TryGetValue(peripheralAddress, out var peripheralId))
{
peripheralId = Guid.NewGuid().ToString();
this.peripheralAddressToId[peripheralAddress] = peripheralId;
}
this.discoveredPeripherals[peripheralId] = peripheral;
return peripheralId;
}
/// <summary>
/// Retrieve a registered peripheral.
/// </summary>
/// <param name="peripheralId">The anonymized peripheral ID.</param>
/// <returns>The peripheral if found, otherwise null.</returns>
protected TPeripheral GetPeripheral(string peripheralId)
{
return this.discoveredPeripherals.GetValueOrDefault(peripheralId, null);
}
/// <summary>
/// Clear all registered peripherals.
/// The mapping of peripheral address to ID will not be cleared. To clear that, start a new session.
/// </summary>
protected void ClearPeripherals()
{
this.discoveredPeripherals.Clear();
}
}

View file

@ -26,9 +26,9 @@ using JsonRpcMethodHandler = System.Func<
using RequestId = System.UInt32;
/// <summary>
/// Base class for Scratch Link sessions. One session can search for, connect to, and interact with one peripheral device.
/// Base class for Scratch Link sessions.
/// </summary>
internal class Session : IDisposable
public class Session : IDisposable
{
/// <summary>
/// Specifies the Scratch Link network protocol version. Note that this is not the application version.

View file

@ -29,5 +29,6 @@
<Compile Include="$(MSBuildThisFileDirectory)SessionManager.cs" />
<Compile Include="$(MSBuildThisFileDirectory)WebSocketListener.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Extensions\SemaphoreSlimExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)PeripheralSession.cs" />
</ItemGroup>
</Project>

View file

@ -22,7 +22,7 @@ using ScratchLink.Mac.Extensions;
/// <summary>
/// Implements a BLE session on MacOS.
/// </summary>
internal class MacBLESession : BLESession<CBUUID>
internal class MacBLESession : BLESession<CBPeripheral, NSUuid, CBUUID>
{
/// <summary>
/// The minimum value for RSSI during discovery: peripherals with a weaker signal will be ignored.
@ -142,25 +142,8 @@ internal class MacBLESession : BLESession<CBUUID>
}
/// <inheritdoc/>
protected override async Task<object> DoConnect(JsonElement jsonPeripheralId)
protected override async Task<object> DoConnect(CBPeripheral peripheral)
{
NSUuid peripheralId = null;
try
{
var peripheralIdString = jsonPeripheralId.GetString();
peripheralId = new NSUuid(peripheralIdString);
}
catch
{
// ignore any exceptions: just check below for a valid peripheralId
}
if (peripheralId == null)
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("malformed peripheralId"));
}
using (await this.filterLock.WaitDisposableAsync(this.CancellationToken))
{
if (this.connectedPeripheral != null)
@ -168,13 +151,8 @@ internal class MacBLESession : BLESession<CBUUID>
throw new JsonRpc2Exception(JsonRpc2Error.InvalidRequest("already connected or connecting"));
}
if (!this.discoveredPeripherals.TryGetValue(peripheralId, out var discoveredPeripheral))
{
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("invalid peripheralId: " + peripheralId));
}
this.cbManager.StopScan();
this.connectedPeripheral = discoveredPeripheral;
this.connectedPeripheral = peripheral;
}
#if DEBUG
@ -417,16 +395,7 @@ internal class MacBLESession : BLESession<CBUUID>
}
// the device must have passed the filter!
this.discoveredPeripherals[peripheral.Identifier] = peripheral;
await this.SendNotification(
"didDiscoverPeripheral",
new BLEPeripheralDiscovered()
{
Name = peripheral.Name,
PeripheralId = peripheral.Identifier.ToString(),
RSSI = rssi.Int32Value,
},
this.CancellationToken);
await this.OnPeripheralDiscovered(peripheral, peripheral.Identifier, peripheral.Name, rssi.Int32Value);
}
private void CbManager_DisconnectedPeripheral(object sender, CBPeripheralErrorEventArgs e)

View file

@ -177,6 +177,6 @@ internal class MacBTSession : BTSession<BluetoothDevice, BluetoothDeviceAddress>
return;
}
await this.OnDeviceFound(e.Device, e.Device.Address, e.Device.NameOrAddress, e.Device.Rssi);
await this.OnPeripheralDiscovered(e.Device, e.Device.Address, e.Device.NameOrAddress, e.Device.Rssi);
}
}