Mac BT: implement 'connect', remove 'ouiPrefix'

This commit is contained in:
Christopher Willis-Ford 2022-07-18 16:36:33 -07:00
parent 8fa4207a2b
commit 5312199e6b
10 changed files with 453 additions and 27 deletions

View file

@ -61,11 +61,8 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("majorDeviceClass and minorDeviceClass required"));
}
// TODO: parse ouiPrefixString to bytes
var ouiPrefixString = args?.TryGetProperty("ouiPrefix")?.GetString();
this.availableDevices.Clear();
return this.DoDiscover((byte)majorDeviceClass, (byte)minorDeviceClass, null);
return this.DoDiscover((byte)majorDeviceClass, (byte)minorDeviceClass);
}
/// <summary>
@ -73,9 +70,8 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
/// </summary>
/// <param name="majorDeviceClass">Discover peripherals with this major device class.</param>
/// <param name="minorDeviceClass">Discover peripherals with this minor device class.</param>
/// <param name="ouiPrefix">If set, discover peripherals matching this 3-byte OUI prefix.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected abstract Task<object> DoDiscover(byte majorDeviceClass, byte minorDeviceClass, byte[] ouiPrefix);
protected abstract Task<object> DoDiscover(byte majorDeviceClass, byte minorDeviceClass);
/// <summary>
/// Implement the JSON-RPC "connect" request to connect to a particular peripheral.
@ -88,7 +84,19 @@ internal abstract class BTSession<TDevice, TDeviceId> : Session
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected Task<object> HandleConnect(string methodName, JsonElement? args)
{
throw new NotImplementedException();
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>

View file

@ -12,7 +12,7 @@ using System.Threading.Tasks;
/// <summary>
/// Adaptor to use "await" with events.
/// Events which fire after construction but before calling <see cref="MakeTask"/> will be queued and can be retrieved later.
/// Events which fire after construction but before calling <c>MakeTask</c> will be queued and can be retrieved later.
/// </summary>
/// <typeparam name="T">The type of EventArgs returned by this event.</typeparam>
public class EventAwaiter<T> : IDisposable
@ -44,6 +44,92 @@ public class EventAwaiter<T> : IDisposable
addHandler(this.EventHandler);
}
/// <summary>
/// Convenience method to create an instance of the <see cref="EventAwaiter{T}"/> class, await its task, dispose of the instance, and return the event task's results.
/// </summary>
/// <param name="targetEvent">The event to hook.</param>
/// <param name="timeout">How long to wait for the event. If the timeout expires the Task will throw a <see cref="TimeoutException"/>.</param>
/// <param name="cancellationToken">The cancellation token to use to cancel the operation.</param>
/// <param name="action">Optional action to perform after hooking the event but before awaiting the event task. Usually this is code which will trigger the event.</param>
/// <returns>A Task which will return the args passed to the event when it triggers.</returns>
public static async ValueTask<T> MakeTask(EventHandler<T> targetEvent, TimeSpan timeout, CancellationToken cancellationToken, Action action = null)
{
using (var awaiter = new EventAwaiter<T>(targetEvent))
{
if (action != null)
{
action();
}
return await awaiter.MakeTask(timeout, cancellationToken);
}
}
/// <summary>
/// Convenience method to create an instance of the <see cref="EventAwaiter{T}"/> class, await its task, dispose of the instance, and return the event task's results.
/// </summary>
/// <param name="targetEvent">The event to hook.</param>
/// <param name="timeout">How long to wait for the event. If the timeout expires the Task will throw a <see cref="TimeoutException"/>.</param>
/// <param name="cancellationToken">The cancellation token to use to cancel the operation.</param>
/// <param name="asyncAction">Optional async action to await after hooking the event but before awaiting the event task. Usually this is code which will trigger the event.</param>
/// <returns>A Task which will return the args passed to the event when it triggers.</returns>
public static async ValueTask<T> MakeTask(EventHandler<T> targetEvent, TimeSpan timeout, CancellationToken cancellationToken, Func<Task> asyncAction = null)
{
using (var awaiter = new EventAwaiter<T>(targetEvent))
{
if (asyncAction != null)
{
await asyncAction();
}
return await awaiter.MakeTask(timeout, cancellationToken);
}
}
/// <summary>
/// Convenience method to create an instance of the <see cref="EventAwaiter{T}"/> class, await its task, dispose of the instance, and return the event task's results.
/// </summary>
/// <param name="addHandler">An action to add a handler to the event, like <c>handler => foo.MyEvent += handler</c>.</param>
/// <param name="removeHandler">An action to remove a handler from the event, like <c>handler => foo.MyEvent += handler</c>.</param>
/// <param name="timeout">How long to wait for the event. If the timeout expires the Task will throw a <see cref="TimeoutException"/>.</param>
/// <param name="cancellationToken">The cancellation token to use to cancel the operation.</param>
/// <param name="action">Optional action to perform after hooking the event but before awaiting the event task. Usually this is code which will trigger the event.</param>
/// <returns>A Task which will return the args passed to the event when it triggers.</returns>
public static async ValueTask<T> MakeTask(Action<EventHandler<T>> addHandler, Action<EventHandler<T>> removeHandler, TimeSpan timeout, CancellationToken cancellationToken, Action action = null)
{
using (var awaiter = new EventAwaiter<T>(addHandler, removeHandler))
{
if (action != null)
{
action();
}
return await awaiter.MakeTask(timeout, cancellationToken);
}
}
/// <summary>
/// Convenience method to create an instance of the <see cref="EventAwaiter{T}"/> class, await its task, dispose of the instance, and return the event task's results.
/// </summary>
/// <param name="addHandler">An action to add a handler to the event, like <c>handler => foo.MyEvent += handler</c>.</param>
/// <param name="removeHandler">An action to remove a handler from the event, like <c>handler => foo.MyEvent += handler</c>.</param>
/// <param name="timeout">How long to wait for the event. If the timeout expires the Task will throw a <see cref="TimeoutException"/>.</param>
/// <param name="cancellationToken">The cancellation token to use to cancel the operation.</param>
/// <param name="asyncAction">Optional async action to await after hooking the event but before awaiting the event task. Usually this is code which will trigger the event.</param>
/// <returns>A Task which will return the args passed to the event when it triggers.</returns>
public static async ValueTask<T> MakeTask(Action<EventHandler<T>> addHandler, Action<EventHandler<T>> removeHandler, TimeSpan timeout, CancellationToken cancellationToken, Func<Task> asyncAction = null)
{
using (var awaiter = new EventAwaiter<T>(addHandler, removeHandler))
{
if (asyncAction != null)
{
await asyncAction();
}
return await awaiter.MakeTask(timeout, cancellationToken);
}
}
/// <summary>
/// Dispose of this <see cref="EventAwaiter{T}"/> instance, including ensuring that the event is no longer hooked.
/// </summary>

View file

@ -6,25 +6,26 @@ namespace ScratchLink.Mac.BT;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Tasks;
using CoreBluetooth;
using Fleck;
using Foundation;
using IOBluetooth;
using ScratchLink.BT;
using ScratchLink.JsonRpc;
using ScratchLink.Mac.BT.Rfcomm;
/// <summary>
/// Implements a BT session on MacOS.
/// </summary>
internal class MacBTSession : BTSession<BluetoothDevice, BluetoothDeviceAddress>
{
private const int KIOReturnSuccess = 0;
private DeviceInquiry inquiry;
private readonly DeviceInquiry inquiry;
private DeviceClassMajor searchClassMajor;
private DeviceClassMinor searchClassMinor;
private byte[] ouiPrefix;
/// <summary>
/// Initializes a new instance of the <see cref="MacBTSession"/> class.
@ -49,7 +50,7 @@ internal class MacBTSession : BTSession<BluetoothDevice, BluetoothDeviceAddress>
}
/// <inheritdoc/>
protected override Task<object> DoDiscover(byte majorDeviceClass, byte minorDeviceClass, byte[] ouiPrefix)
protected override Task<object> DoDiscover(byte majorDeviceClass, byte minorDeviceClass)
{
this.inquiry.Stop();
this.inquiry.ClearFoundDevices();
@ -59,9 +60,10 @@ internal class MacBTSession : BTSession<BluetoothDevice, BluetoothDeviceAddress>
this.inquiry.SearchType = DeviceSearchType.Classic;
this.inquiry.InquiryLength = 20;
this.inquiry.UpdateNewDeviceNames = true;
var inquiryStatus = this.inquiry.Start();
if (inquiryStatus != KIOReturnSuccess)
var inquiryStatus = (IOReturn)this.inquiry.Start();
if (inquiryStatus != IOReturn.Success)
{
Debug.Print("Failed to start inquiry: {0}", inquiryStatus.ToDebugString());
throw new JsonRpc2Exception(JsonRpc2Error.ServerError(-32500, "Device inquiry failed to start"));
}
@ -69,9 +71,54 @@ internal class MacBTSession : BTSession<BluetoothDevice, BluetoothDeviceAddress>
}
/// <inheritdoc/>
protected override Task<object> DoConnect(BluetoothDevice device)
protected override async Task<object> DoConnect(BluetoothDevice device)
{
throw new System.NotImplementedException();
this.inquiry.Stop();
var rfcommDelegate = new RfcommChannelEventDelegate();
rfcommDelegate.RfcommChannelDataEvent += this.RfcommDelegate_RfcommChannelData;
var openChannelResult = await EventAwaiter<RfcommChannelOpenCompleteEventArgs>.MakeTask(
h => rfcommDelegate.RfcommChannelOpenCompleteEvent += h,
h => rfcommDelegate.RfcommChannelOpenCompleteEvent -= h,
TimeSpan.FromSeconds(15),
this.CancellationToken,
() =>
{
// OpenRfcommChannelSync sometimes returns "general error" even when the connection will succeed later.
// Ignore its return value and check for error status on the RfcommChannelOpenComplete event instead.
device.OpenRfcommChannelSync(out var connectedChannel, 1, rfcommDelegate);
});
if (openChannelResult.Error != IOReturn.Success)
{
Debug.Print("Opening RFCOMM channel failed: {0}", openChannelResult.Error.ToDebugString());
throw new JsonRpc2Exception(JsonRpc2Error.ServerError(-32500, "Could not connect to RFCOMM channel."));
}
var connectedChannel = openChannelResult.Channel;
// Connect is done already; don't wait for this run loop / session to complete.
_ = Task.Run(async () =>
{
// run loop specifically for this device: necessary to get delegate callbacks
// TODO: is it actually necessary with this new implementation?
while (connectedChannel?.IsOpen ?? false)
{
await Task.Delay(500);
}
await this.SendErrorNotification(JsonRpc2Error.ApplicationError("RFCOMM run loop exited"), this.CancellationToken);
this.EndSession();
});
return null;
}
private void RfcommDelegate_RfcommChannelData(object sender, RfcommChannelDataEventArgs e)
{
Debug.Print("Received {0} bytes", e.Data.Length);
}
private async void Inquiry_DeviceFoundAsync(object sender, DeviceFoundEventArgs e)
@ -90,16 +137,6 @@ internal class MacBTSession : BTSession<BluetoothDevice, BluetoothDeviceAddress>
return;
}
if (this.ouiPrefix != null)
{
if ((this.ouiPrefix[0] != e.Device.Address.Data[0]) ||
(this.ouiPrefix[1] != e.Device.Address.Data[1]) ||
(this.ouiPrefix[2] != e.Device.Address.Data[2]))
{
return;
}
}
await this.OnDeviceFound(e.Device, e.Device.Address, e.Device.NameOrAddress, e.Device.Rssi);
}
}

View file

@ -0,0 +1,18 @@
// <copyright file="RfcommChannelDataEventArgs.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink.Mac.BT.Rfcomm;
using System;
/// <summary>
/// Event args for receiving data through an RFCOMM channel.
/// </summary>
public class RfcommChannelDataEventArgs : RfcommChannelEventArgs
{
/// <summary>
/// Gets or sets the data received through the RFCOMM channel.
/// </summary>
public byte[] Data { get; set; }
}

View file

@ -0,0 +1,19 @@
// <copyright file="RfcommChannelEventArgs.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink.Mac.BT.Rfcomm;
using System;
using IOBluetooth;
/// <summary>
/// Generic event args for an RFCOMM channel event.
/// </summary>
public class RfcommChannelEventArgs : EventArgs
{
/// <summary>
/// Gets or sets the channel associated with the event.
/// </summary>
public RfcommChannel Channel { get; set; }
}

View file

@ -0,0 +1,169 @@
// <copyright file="RfcommChannelEventDelegate.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink.Mac.BT.Rfcomm;
using System;
using IOBluetooth;
using System.Diagnostics;
using System.Runtime.InteropServices;
/// <summary>
/// Converts <see cref="RfcommChannelDelegate"/> callbacks into events.
/// </summary>
public class RfcommChannelEventDelegate : RfcommChannelDelegate
{
/// <summary>
/// Event triggered when the RFCOMM channel has closed.
/// </summary>
public event EventHandler<RfcommChannelEventArgs> RfcommChannelClosedEvent;
/// <summary>
/// Event triggered when the RFCOMM channel's control signals have changed.
/// </summary>
public event EventHandler<RfcommChannelEventArgs> RfcommChannelControlSignalsChangedEvent;
/// <summary>
/// Event triggered when the RFCOMM channel has received data.
/// </summary>
public event EventHandler<RfcommChannelDataEventArgs> RfcommChannelDataEvent;
/// <summary>
/// Event triggered when the RFCOMM channel's flow control has changed.
/// </summary>
public event EventHandler<RfcommChannelEventArgs> RfcommChannelFlowControlChangedEvent;
/// <summary>
/// Event triggered when opening the RFCOMM has completed (succeeded or failed).
/// </summary>
public event EventHandler<RfcommChannelOpenCompleteEventArgs> RfcommChannelOpenCompleteEvent;
/// <summary>
/// Event triggered when there is space in the RFCOMM channel's queue.
/// </summary>
public event EventHandler<RfcommChannelEventArgs> RfcommChannelQueueSpaceAvailableEvent;
/// <summary>
/// Event triggered when a write to the RFCOMM channel has completed.
/// </summary>
public event EventHandler<RfcommChannelWriteCompleteEventArgs> RfcommChannelWriteCompleteEvent;
/// <summary>
/// Callback triggered when the RFCOMM channel has closed.
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
public override void RfcommChannelClosed(RfcommChannel rfcommChannel)
{
if (this.RfcommChannelClosedEvent == null)
{
return;
}
this.RfcommChannelClosedEvent.Invoke(rfcommChannel, new RfcommChannelEventArgs { Channel = rfcommChannel });
}
/// <summary>
/// Callback triggered when the RFCOMM channel's control signals have changed.
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
public override void RfcommChannelControlSignalsChanged(RfcommChannel rfcommChannel)
{
if (this.RfcommChannelControlSignalsChangedEvent == null)
{
return;
}
this.RfcommChannelControlSignalsChangedEvent.Invoke(rfcommChannel, new RfcommChannelEventArgs { Channel = rfcommChannel });
}
/// <summary>
/// Callback triggered when the RFCOMM channel has received data.
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
/// <param name="dataPointer">A pointer to the data received.</param>
/// <param name="dataLength">The number of bytes of data received.</param>
public override void RfcommChannelData(RfcommChannel rfcommChannel, IntPtr dataPointer, nuint dataLength)
{
if (this.RfcommChannelDataEvent == null)
{
return;
}
var output = new byte[dataLength];
Marshal.Copy(dataPointer, output, 0, (int)dataLength);
this.RfcommChannelDataEvent.Invoke(rfcommChannel, new RfcommChannelDataEventArgs
{
Channel = rfcommChannel,
Data = output,
});
}
/// <summary>
/// Callback triggered when the RFCOMM channel's flow control has changed.
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
public override void RfcommChannelFlowControlChanged(RfcommChannel rfcommChannel)
{
if (this.RfcommChannelFlowControlChangedEvent == null)
{
return;
}
this.RfcommChannelFlowControlChangedEvent.Invoke(rfcommChannel, new RfcommChannelEventArgs { Channel = rfcommChannel });
}
/// <summary>
/// Callback triggered when opening the RFCOMM has completed (succeeded or failed).
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
/// <param name="error">An <c>IOReturn</c> whether opening the channel succeeded or failed.</param>
public override void RfcommChannelOpenComplete(RfcommChannel rfcommChannel, int error)
{
if (this.RfcommChannelOpenCompleteEvent == null)
{
return;
}
this.RfcommChannelOpenCompleteEvent.Invoke(rfcommChannel, new RfcommChannelOpenCompleteEventArgs
{
Channel = rfcommChannel,
Error = (IOReturn)error,
});
}
/// <summary>
/// Callback triggered when there is space in the RFCOMM channel's queue.
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
public override void RfcommChannelQueueSpaceAvailable(RfcommChannel rfcommChannel)
{
if (this.RfcommChannelQueueSpaceAvailableEvent == null)
{
return;
}
this.RfcommChannelQueueSpaceAvailableEvent.Invoke(rfcommChannel, new RfcommChannelEventArgs { Channel = rfcommChannel });
}
/// <summary>
/// Callback triggered when a write to the RFCOMM channel has completed.
/// </summary>
/// <param name="rfcommChannel">The RFCOMM channel for which this callback is being called.</param>
/// <param name="refcon">The "reference constant" passed when initiating this write.</param>
/// <param name="error">The error encountered during this write, if any.</param>
public override void RfcommChannelWriteComplete(RfcommChannel rfcommChannel, IntPtr refcon, int error)
{
if (this.RfcommChannelWriteCompleteEvent == null)
{
return;
}
this.RfcommChannelWriteCompleteEvent.Invoke(rfcommChannel, new RfcommChannelWriteCompleteEventArgs
{
Channel = rfcommChannel,
RefCon = refcon,
Error = (IOReturn)error,
});
}
}

View file

@ -0,0 +1,18 @@
// <copyright file="RfcommChannelOpenCompleteEventArgs.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink.Mac.BT.Rfcomm;
using System;
/// <summary>
/// Event args for when opening an RFCOMM channel has completed (succeeded or failed).
/// </summary>
public class RfcommChannelOpenCompleteEventArgs : RfcommChannelEventArgs
{
/// <summary>
/// Gets or sets the error encountered while attempting to open the RFCOMM channel, if any.
/// </summary>
public IOReturn Error { get; set; }
}

View file

@ -0,0 +1,23 @@
// <copyright file="RfcommChannelWriteCompleteEventArgs.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink.Mac.BT.Rfcomm;
using System;
/// <summary>
/// Event args for the completion of an RFCOMM write.
/// </summary>
public class RfcommChannelWriteCompleteEventArgs : RfcommChannelEventArgs
{
/// <summary>
/// Gets or sets the reference constant.
/// </summary>
public IntPtr RefCon { get; set; }
/// <summary>
/// Gets or sets an <c>IOReturn</c> value indicating whether an error occurred during the write.
/// </summary>
public IOReturn Error { get; set; }
}

View file

@ -0,0 +1,42 @@
// <copyright file="IOReturn.cs" company="Scratch Foundation">
// Copyright (c) Scratch Foundation. All rights reserved.
// </copyright>
namespace ScratchLink.Mac;
using System;
using System.Runtime.InteropServices;
/// <summary>
/// Corresponds to <c>IOReturn</c> in <c>IOKit</c>, also known as <c>kern_return_t</c>.
/// </summary>
public enum IOReturn : int
{
/// <summary>
/// Success.
/// </summary>
Success = 0,
}
/// <summary>
/// Extensions for <see cref="IOReturn"/>.
/// </summary>
public static class IOReturnExtensions
{
/// <summary>
/// Converts an <see cref="IOReturn"/> value to a developer-friendly string.
/// This uses <c>mach_error_string</c> from the macOS system.
/// The resulting string might help a developer but they're not usually friendly to lay-people.
/// </summary>
/// <param name="ioReturn">The value to convert.</param>
/// <returns>A developer-readable string corresponding to the error code.</returns>
public static string ToDebugString(this IOReturn ioReturn)
{
var ptr = Mach_error_string(ioReturn);
var str = Marshal.PtrToStringAuto(ptr);
return str;
}
[DllImport("__Internal", EntryPoint = "mach_error_string")]
private static extern IntPtr Mach_error_string([MarshalAs(UnmanagedType.I4)] IOReturn ioReturn);
}

View file

@ -88,7 +88,13 @@
<Compile Include="BLE\MacBLESession.cs" />
<Compile Include="BLE\MacBLEEndpoint.cs" />
<Compile Include="BT\MacBTSession.cs" />
<Compile Include="BT\Rfcomm\RfcommChannelDataEventArgs.cs" />
<Compile Include="BT\Rfcomm\RfcommChannelEventDelegate.cs" />
<Compile Include="BT\Rfcomm\RfcommChannelEventArgs.cs" />
<Compile Include="BT\Rfcomm\RfcommChannelOpenCompleteEventArgs.cs" />
<Compile Include="BT\Rfcomm\RfcommChannelWriteCompleteEventArgs.cs" />
<Compile Include="Extensions\NSExtensions.cs" />
<Compile Include="IOReturn.cs" />
<Compile Include="MacSessionManager.cs" />
</ItemGroup>
<ItemGroup>