304 lines
13 KiB
C#
304 lines
13 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.Runtime.InteropServices.WindowsRuntime;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Channels;
|
|
using System.Threading.Tasks;
|
|
using Windows.Devices.Bluetooth;
|
|
using Windows.Devices.Bluetooth.Advertisement;
|
|
using Windows.Devices.Bluetooth.GenericAttributeProfile;
|
|
using Windows.Storage.Streams;
|
|
|
|
namespace KVMote.Transport
|
|
{
|
|
internal sealed class BleTransport : IKvmTransport
|
|
{
|
|
// Nordic UART Service (NUS) — convenção padrão para serial-over-BLE
|
|
private static readonly Guid NusServiceUuid = Guid.Parse("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
|
|
private static readonly Guid NusRxUuid = Guid.Parse("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"); // PC escreve aqui
|
|
private static readonly Guid NusTxUuid = Guid.Parse("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"); // PC lê (notify)
|
|
|
|
private const string AdvertisedName = "KVMote";
|
|
private const int ScanTimeoutMs = 3000;
|
|
|
|
private ulong _address;
|
|
private BluetoothLEDevice? _device;
|
|
private GattDeviceService? _service;
|
|
private GattCharacteristic? _rxChar;
|
|
private GattCharacteristic? _txChar;
|
|
private GattWriteOption _writeOption;
|
|
private bool _connected;
|
|
private bool _disposed;
|
|
|
|
private Channel<byte[]>? _sendChannel;
|
|
private CancellationTokenSource? _sendCts;
|
|
|
|
public string DeviceLabel => _address != 0 ? "KVMote (BLE)" : "—";
|
|
public bool IsConnected => _connected &&
|
|
_device?.ConnectionStatus == BluetoothConnectionStatus.Connected;
|
|
public int ClipboardMaxChars => 1000;
|
|
public int ClipboardDelayMs => 5;
|
|
|
|
public event Action<string>? DataReceived;
|
|
|
|
// ── Detection ──────────────────────────────────────────────────────────
|
|
|
|
public async Task<bool> DetectAsync(CancellationToken ct)
|
|
{
|
|
// Fast path: endereço já conhecido de conexão anterior — não precisa escanear
|
|
if (_address != 0) return true;
|
|
|
|
// Timeout interno para que o loop de retry em AutoDetectAsync funcione
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
timeoutCts.CancelAfter(ScanTimeoutMs);
|
|
var token = timeoutCts.Token;
|
|
|
|
var tcs = new TaskCompletionSource<ulong>();
|
|
var watcher = new BluetoothLEAdvertisementWatcher
|
|
{
|
|
ScanningMode = BluetoothLEScanningMode.Active
|
|
};
|
|
|
|
watcher.Received += (_, args) =>
|
|
{
|
|
if (args.Advertisement.LocalName == AdvertisedName)
|
|
tcs.TrySetResult(args.BluetoothAddress);
|
|
};
|
|
|
|
watcher.Start();
|
|
try
|
|
{
|
|
_address = await tcs.Task.WaitAsync(token);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
_address = 0;
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
watcher.Stop();
|
|
}
|
|
}
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
|
|
public async Task<bool> ConnectAsync()
|
|
{
|
|
try
|
|
{
|
|
Log("BLE: FromBluetoothAddressAsync...");
|
|
_device = await BluetoothLEDevice.FromBluetoothAddressAsync(_address).AsTask();
|
|
if (_device is null) { Log("BLE: device null"); return false; }
|
|
_device.ConnectionStatusChanged += OnConnectionStatusChanged;
|
|
|
|
// Aguarda conexão física estabelecer (Windows pode demorar até 3s)
|
|
Log("BLE: aguardando ConnectionStatus...");
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
if (_device.ConnectionStatus == BluetoothConnectionStatus.Connected) break;
|
|
await System.Threading.Tasks.Task.Delay(300);
|
|
}
|
|
Log($"BLE: ConnectionStatus={_device.ConnectionStatus}");
|
|
|
|
// Aguarda mais um momento após Connected — Windows ainda finaliza GATT anterior
|
|
if (_device.ConnectionStatus == BluetoothConnectionStatus.Connected)
|
|
await System.Threading.Tasks.Task.Delay(800);
|
|
|
|
// Retry GATT discovery — busca todos os serviços e filtra manualmente
|
|
// (GetGattServicesForUuidAsync falha com cache stale no Windows)
|
|
for (int attempt = 1; attempt <= 4; attempt++)
|
|
{
|
|
Log($"BLE: GetGattServicesAsync (tentativa {attempt})...");
|
|
try
|
|
{
|
|
var all = await _device
|
|
.GetGattServicesAsync(BluetoothCacheMode.Uncached)
|
|
.AsTask();
|
|
Log($"BLE: GetGattServicesAsync status={all.Status} total={all.Services.Count}");
|
|
if (all.Status == GattCommunicationStatus.Success)
|
|
{
|
|
foreach (var s in all.Services)
|
|
if (s.Uuid == NusServiceUuid) { _service = s; break; }
|
|
if (_service is not null) { Log("BLE: NUS encontrado!"); break; }
|
|
Log("BLE: NUS não encontrado na lista.");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log($"BLE: GATT tentativa {attempt} [{ex.GetType().Name}] hr=0x{ex.HResult:X8}");
|
|
}
|
|
await System.Threading.Tasks.Task.Delay(1500);
|
|
}
|
|
|
|
if (_service is null) return false;
|
|
|
|
Log("BLE: GetCharacteristics RX/TX...");
|
|
var rxResult = await _service.GetCharacteristicsForUuidAsync(NusRxUuid).AsTask();
|
|
var txResult = await _service.GetCharacteristicsForUuidAsync(NusTxUuid).AsTask();
|
|
|
|
Log($"BLE: rx={rxResult.Status}/{rxResult.Characteristics.Count} tx={txResult.Status}/{txResult.Characteristics.Count}");
|
|
|
|
// AccessDenied = Windows tem bond stale; desparea, reseta endereço e força
|
|
// novo scan BLE na próxima tentativa (evita ciclo de AccessDenied infinito)
|
|
if (rxResult.Status == GattCommunicationStatus.AccessDenied ||
|
|
txResult.Status == GattCommunicationStatus.AccessDenied)
|
|
{
|
|
Log("BLE: AccessDenied — despareando e resetando endereço...");
|
|
try
|
|
{
|
|
var devInfo = await Windows.Devices.Enumeration.DeviceInformation
|
|
.CreateFromIdAsync(_device.DeviceId).AsTask();
|
|
if (devInfo.Pairing.IsPaired)
|
|
{
|
|
var r = await devInfo.Pairing.UnpairAsync().AsTask();
|
|
Log($"BLE: Unpair={r.Status}");
|
|
}
|
|
}
|
|
catch (Exception ex) { Log($"BLE: Unpair ex: {ex.Message}"); }
|
|
_address = 0; // força novo scan BLE — evita reuso de bond stale do Windows
|
|
Disconnect();
|
|
await System.Threading.Tasks.Task.Delay(2000); // aguarda Windows processar unpair
|
|
return false;
|
|
}
|
|
|
|
if (rxResult.Status != GattCommunicationStatus.Success ||
|
|
rxResult.Characteristics.Count == 0) return false;
|
|
if (txResult.Status != GattCommunicationStatus.Success ||
|
|
txResult.Characteristics.Count == 0) return false;
|
|
|
|
_rxChar = rxResult.Characteristics[0];
|
|
_txChar = txResult.Characteristics[0];
|
|
|
|
// Prefere WriteWithoutResponse (mais rápido) se suportado
|
|
_writeOption = _rxChar.CharacteristicProperties
|
|
.HasFlag(GattCharacteristicProperties.WriteWithoutResponse)
|
|
? GattWriteOption.WriteWithoutResponse
|
|
: GattWriteOption.WriteWithResponse;
|
|
|
|
Log("BLE: Subscribing notify...");
|
|
// Inscreve em notificações para receber [PONG]
|
|
var cccdStatus = await _txChar
|
|
.WriteClientCharacteristicConfigurationDescriptorAsync(
|
|
GattClientCharacteristicConfigurationDescriptorValue.Notify)
|
|
.AsTask();
|
|
Log($"BLE: cccd={cccdStatus}");
|
|
if (cccdStatus != GattCommunicationStatus.Success) return false;
|
|
|
|
_txChar.ValueChanged += OnValueChanged;
|
|
_connected = true;
|
|
StartSendLoop();
|
|
Log("BLE: Connected OK");
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log($"BLE: ConnectAsync exception [{ex.GetType().Name}] hr=0x{ex.HResult:X8}: {ex.Message}");
|
|
Disconnect();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void Disconnect()
|
|
{
|
|
_connected = false;
|
|
StopSendLoop();
|
|
|
|
if (_txChar is not null)
|
|
{
|
|
try { _txChar.ValueChanged -= OnValueChanged; } catch { }
|
|
_txChar = null;
|
|
}
|
|
_rxChar = null;
|
|
|
|
if (_service is not null)
|
|
{
|
|
try { _service.Dispose(); } catch { }
|
|
_service = null;
|
|
}
|
|
|
|
if (_device is not null)
|
|
{
|
|
_device.ConnectionStatusChanged -= OnConnectionStatusChanged;
|
|
try { _device.Dispose(); } catch { }
|
|
_device = null;
|
|
}
|
|
}
|
|
|
|
// ── Send ───────────────────────────────────────────────────────────────
|
|
|
|
public void Send(byte[] data) => _sendChannel?.Writer.TryWrite(data);
|
|
public void SendLossy(byte[] data) => _sendChannel?.Writer.TryWrite(data);
|
|
|
|
// ── Send loop (Channel interno — mantém interface síncrona) ────────────
|
|
|
|
private void StartSendLoop()
|
|
{
|
|
_sendCts = new CancellationTokenSource();
|
|
_sendChannel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(64)
|
|
{
|
|
FullMode = BoundedChannelFullMode.DropOldest
|
|
});
|
|
|
|
var ct = _sendCts.Token;
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await foreach (var data in _sendChannel.Reader.ReadAllAsync(ct))
|
|
{
|
|
if (_rxChar is null) break;
|
|
try
|
|
{
|
|
await _rxChar.WriteValueAsync(data.AsBuffer(), _writeOption).AsTask();
|
|
}
|
|
catch { break; }
|
|
}
|
|
}, ct);
|
|
}
|
|
|
|
private void StopSendLoop()
|
|
{
|
|
_sendChannel?.Writer.TryComplete();
|
|
_sendCts?.Cancel();
|
|
_sendCts = null;
|
|
_sendChannel = null;
|
|
}
|
|
|
|
// ── Callbacks ──────────────────────────────────────────────────────────
|
|
|
|
private void OnValueChanged(GattCharacteristic _, GattValueChangedEventArgs args)
|
|
{
|
|
var reader = DataReader.FromBuffer(args.CharacteristicValue);
|
|
var bytes = new byte[args.CharacteristicValue.Length];
|
|
reader.ReadBytes(bytes);
|
|
DataReceived?.Invoke(Encoding.ASCII.GetString(bytes));
|
|
}
|
|
|
|
private void OnConnectionStatusChanged(BluetoothLEDevice _, object __)
|
|
{
|
|
if (_device?.ConnectionStatus == BluetoothConnectionStatus.Disconnected)
|
|
_connected = false;
|
|
}
|
|
|
|
private static readonly string LogFile =
|
|
System.IO.Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
|
|
"kvmote_ble.log");
|
|
|
|
private static void Log(string msg)
|
|
{
|
|
string line = $"[BLE {DateTime.Now:HH:mm:ss.fff}] {msg}";
|
|
Debug.WriteLine(line);
|
|
try { System.IO.File.AppendAllText(LogFile, line + "\n"); } catch { }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
Disconnect();
|
|
}
|
|
}
|
|
}
|