using System.Text; using Plugin.BLE; using Plugin.BLE.Abstractions; using Plugin.BLE.Abstractions.Contracts; using Plugin.BLE.Abstractions.EventArgs; namespace FieldLogger.Services.Ble; /// /// Line-oriented serial client over a BLE GATT "UART" style service /// (one notify characteristic for RX, one write characteristic for TX). /// Both the UM receiver and the Maglink RTK receiver expose this pattern. /// public sealed class BleSerialClient : IAsyncDisposable { private readonly IAdapter _adapter; private readonly StringBuilder _rxBuffer = new(); private IDevice? _device; private ICharacteristic? _notifyChar; private ICharacteristic? _writeChar; public event EventHandler? LineReceived; public event EventHandler? Disconnected; public bool IsConnected => _device?.State == DeviceState.Connected; public string? DeviceName => _device?.Name; public Guid? DeviceId => _device?.Id; public BleSerialClient() { _adapter = CrossBluetoothLE.Current.Adapter; _adapter.DeviceDisconnected += OnDeviceDisconnected; _adapter.DeviceConnectionLost += OnDeviceConnectionLost; } /// /// Connects to a known device by id, discovers the given service, and /// enables notifications. Throws on failure. /// public async Task ConnectAsync(Guid deviceId, Guid serviceUuid, Guid notifyUuid, Guid writeUuid, CancellationToken cancellationToken = default) { await DisconnectAsync().ConfigureAwait(false); // Add a timeout to prevent hanging indefinitely when device is not available using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); var device = await _adapter.ConnectToKnownDeviceAsync( deviceId, new ConnectParameters(autoConnect: false, forceBleTransport: true), timeoutCts.Token).ConfigureAwait(false); try { // UM receivers request MTU 247; only has an effect on Android, harmless elsewhere. try { await device.RequestMtuAsync(247).ConfigureAwait(false); } catch { /* not supported on this platform */ } var service = await device.GetServiceAsync(serviceUuid, timeoutCts.Token).ConfigureAwait(false) ?? throw new InvalidOperationException($"Service {serviceUuid} not found on {device.Name}."); var notifyChar = await service.GetCharacteristicAsync(notifyUuid).ConfigureAwait(false) ?? throw new InvalidOperationException($"Notify characteristic {notifyUuid} not found."); var writeChar = await service.GetCharacteristicAsync(writeUuid).ConfigureAwait(false) ?? throw new InvalidOperationException($"Write characteristic {writeUuid} not found."); writeChar.WriteType = writeChar.Properties.HasFlag(CharacteristicPropertyType.WriteWithoutResponse) ? CharacteristicWriteType.WithoutResponse : CharacteristicWriteType.WithResponse; Console.WriteLine($"BLE: Subscribing to notifications on {notifyUuid}"); notifyChar.ValueUpdated += OnValueUpdated; await notifyChar.StartUpdatesAsync(timeoutCts.Token).ConfigureAwait(false); Console.WriteLine($"BLE: Successfully subscribed to notifications"); _device = device; _notifyChar = notifyChar; _writeChar = writeChar; Console.WriteLine($"BLE: Connection complete. Device={device.Name}, CanWrite={writeChar.CanWrite}, CanNotify={notifyChar.Properties.HasFlag(CharacteristicPropertyType.Notify)}"); } catch { try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { } throw; } } /// Writes an ASCII string followed by CR+LF to the device. public Task WriteLineAsync(string text, CancellationToken cancellationToken = default) => WriteAsync(text + "\r\n", cancellationToken); public async Task WriteAsync(string text, CancellationToken cancellationToken = default) { var writeChar = _writeChar ?? throw new InvalidOperationException("Not connected."); var bytes = Encoding.ASCII.GetBytes(text); Console.WriteLine($"BLE TX: {text.Replace("\r", "\\r").Replace("\n", "\\n")} ({bytes.Length} bytes)"); await writeChar.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); Console.WriteLine("BLE TX: Write completed"); } public async Task DisconnectAsync() { var device = _device; var notifyChar = _notifyChar; _device = null; _notifyChar = null; _writeChar = null; if (notifyChar is not null) { notifyChar.ValueUpdated -= OnValueUpdated; try { await notifyChar.StopUpdatesAsync().ConfigureAwait(false); } catch { } } if (device is not null) { try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { } device.Dispose(); } lock (_rxBuffer) _rxBuffer.Clear(); } private void OnValueUpdated(object? sender, CharacteristicUpdatedEventArgs e) { var bytes = e.Characteristic.Value; Console.WriteLine($"BLE RX: {bytes?.Length ?? 0} bytes"); if (bytes is null || bytes.Length == 0) return; List lines = new(); lock (_rxBuffer) { var text = Encoding.ASCII.GetString(bytes); Console.WriteLine($"BLE RX decoded: {text.Replace("\r", "\\r").Replace("\n", "\\n")}"); _rxBuffer.Append(text); var buffered = _rxBuffer.ToString(); int newline; while ((newline = buffered.IndexOf('\n')) >= 0) { var line = buffered[..newline].TrimEnd('\r'); buffered = buffered[(newline + 1)..]; if (line.Length > 0) lines.Add(line); } // If no newlines found but we have data, treat each notification as a complete message // This handles devices that send complete messages per notification without line endings if (lines.Count == 0 && buffered.Length > 0) { lines.Add(buffered.Trim()); buffered = ""; } _rxBuffer.Clear(); _rxBuffer.Append(buffered); } Console.WriteLine($"BLE RX: {lines.Count} complete lines"); foreach (var line in lines) { Console.WriteLine($"BLE RX line: {line}"); LineReceived?.Invoke(this, line); } } private void OnDeviceDisconnected(object? sender, DeviceEventArgs e) => HandleDisconnect(e.Device); private void OnDeviceConnectionLost(object? sender, DeviceErrorEventArgs e) => HandleDisconnect(e.Device); private void HandleDisconnect(IDevice device) { if (_device is null || device.Id != _device.Id) return; _device = null; _notifyChar = null; _writeChar = null; Disconnected?.Invoke(this, EventArgs.Empty); } public async ValueTask DisposeAsync() { _adapter.DeviceDisconnected -= OnDeviceDisconnected; _adapter.DeviceConnectionLost -= OnDeviceConnectionLost; await DisconnectAsync().ConfigureAwait(false); } }