140 lines
5.6 KiB
C#
140 lines
5.6 KiB
C#
using Plugin.BLE;
|
|
using Plugin.BLE.Abstractions.Contracts;
|
|
using Plugin.BLE.Abstractions.EventArgs;
|
|
|
|
namespace FieldLogger.Services.Ble;
|
|
|
|
/// <summary>A device found during a BLE scan.</summary>
|
|
public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi);
|
|
|
|
/// <summary>Thin scan wrapper over Plugin.BLE with per-platform permission handling.</summary>
|
|
public sealed class BleScanner
|
|
{
|
|
private readonly IBluetoothLE _bluetooth = CrossBluetoothLE.Current;
|
|
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
|
|
|
|
public event EventHandler<DiscoveredDevice>? DeviceDiscovered;
|
|
|
|
public bool IsScanning => _adapter.IsScanning;
|
|
|
|
/// <summary>
|
|
/// Scans for BLE devices for the given duration, raising DeviceDiscovered as devices appear.
|
|
/// Only named devices are reported.
|
|
/// </summary>
|
|
public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
|
|
{
|
|
await EnsurePermissionsAsync();
|
|
await EnsureBluetoothReadyAsync(cancellationToken);
|
|
|
|
void OnDiscovered(object? sender, DeviceEventArgs e)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(e.Device.Name))
|
|
DeviceDiscovered?.Invoke(this, new DiscoveredDevice(e.Device.Id, e.Device.Name, e.Device.Rssi));
|
|
}
|
|
|
|
_adapter.DeviceDiscovered += OnDiscovered;
|
|
_adapter.DeviceAdvertised += OnDiscovered;
|
|
try
|
|
{
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(duration);
|
|
_adapter.ScanTimeout = (int)duration.TotalMilliseconds;
|
|
try
|
|
{
|
|
await _adapter.StartScanningForDevicesAsync(cancellationToken: timeout.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Scan window elapsed or caller cancelled - both are normal.
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_adapter.DeviceDiscovered -= OnDiscovered;
|
|
_adapter.DeviceAdvertised -= OnDiscovered;
|
|
if (_adapter.IsScanning)
|
|
await _adapter.StopScanningForDevicesAsync();
|
|
}
|
|
}
|
|
|
|
private async Task EnsureBluetoothReadyAsync(CancellationToken cancellationToken)
|
|
{
|
|
// CoreBluetooth commonly starts in Unknown while CBCentralManager is being
|
|
// initialized. IsOn is false for every state except On, so checking it
|
|
// immediately incorrectly reports that Bluetooth is switched off on macOS.
|
|
if (_bluetooth.State is BluetoothState.Unknown or BluetoothState.TurningOn)
|
|
{
|
|
var stateChanged = new TaskCompletionSource<BluetoothState>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
void OnStateChanged(object? sender, BluetoothStateChangedArgs args)
|
|
{
|
|
if (args.NewState is not (BluetoothState.Unknown or BluetoothState.TurningOn))
|
|
stateChanged.TrySetResult(args.NewState);
|
|
}
|
|
|
|
_bluetooth.StateChanged += OnStateChanged;
|
|
try
|
|
{
|
|
// Recheck after subscribing so a transition cannot be missed.
|
|
var currentState = _bluetooth.State;
|
|
if (currentState is BluetoothState.Unknown or BluetoothState.TurningOn)
|
|
{
|
|
using var initializationTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
|
cancellationToken, initializationTimeout.Token);
|
|
try
|
|
{
|
|
await stateChanged.Task.WaitAsync(linkedCancellation.Token);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Bluetooth did not finish initializing. Try scanning again.");
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_bluetooth.StateChanged -= OnStateChanged;
|
|
}
|
|
}
|
|
|
|
if (_bluetooth.State == BluetoothState.On)
|
|
return;
|
|
|
|
throw _bluetooth.State switch
|
|
{
|
|
BluetoothState.Off or BluetoothState.TurningOff =>
|
|
new InvalidOperationException("Bluetooth is turned off."),
|
|
BluetoothState.Unauthorized =>
|
|
new PermissionException(
|
|
"Bluetooth access is denied. Enable Field Logger in System Settings > Privacy & Security > Bluetooth."),
|
|
BluetoothState.Unavailable =>
|
|
new InvalidOperationException("Bluetooth is not available on this Mac."),
|
|
_ => new InvalidOperationException($"Bluetooth is not ready (state: {_bluetooth.State}).")
|
|
};
|
|
}
|
|
|
|
/// <summary>Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).</summary>
|
|
public static async Task EnsurePermissionsAsync()
|
|
{
|
|
#if ANDROID
|
|
if (OperatingSystem.IsAndroidVersionAtLeast(31))
|
|
{
|
|
var status = await Permissions.RequestAsync<Permissions.Bluetooth>();
|
|
if (status != PermissionStatus.Granted)
|
|
throw new PermissionException("Bluetooth permission was denied.");
|
|
}
|
|
else
|
|
{
|
|
var status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();
|
|
if (status != PermissionStatus.Granted)
|
|
throw new PermissionException("Location permission (required for BLE scanning) was denied.");
|
|
}
|
|
#else
|
|
await Task.CompletedTask;
|
|
#endif
|
|
}
|
|
}
|