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