KVMote/Transport/BleTransport.cs

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();
}
}
}