Initial commit: FieldLogger MAUI app with Maglink BLE support
Added Maglink RTK GNSS receiver integration with correct BLE UUIDs and device name filtering (ML-* prefix). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
81
FieldLogger/Services/Ble/BleScanner.cs
Normal file
81
FieldLogger/Services/Ble/BleScanner.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
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 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();
|
||||
|
||||
if (!CrossBluetoothLE.Current.IsOn)
|
||||
throw new InvalidOperationException("Bluetooth is turned off.");
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
}
|
||||
}
|
||||
165
FieldLogger/Services/Ble/BleSerialClient.cs
Normal file
165
FieldLogger/Services/Ble/BleSerialClient.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using System.Text;
|
||||
using Plugin.BLE;
|
||||
using Plugin.BLE.Abstractions;
|
||||
using Plugin.BLE.Abstractions.Contracts;
|
||||
using Plugin.BLE.Abstractions.EventArgs;
|
||||
|
||||
namespace FieldLogger.Services.Ble;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to a known device by id, discovers the given service, and
|
||||
/// enables notifications. Throws on failure.
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(Guid deviceId, Guid serviceUuid, Guid notifyUuid, Guid writeUuid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await DisconnectAsync().ConfigureAwait(false);
|
||||
|
||||
var device = await _adapter.ConnectToKnownDeviceAsync(
|
||||
deviceId,
|
||||
new ConnectParameters(autoConnect: false, forceBleTransport: true),
|
||||
cancellationToken).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, cancellationToken).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;
|
||||
|
||||
notifyChar.ValueUpdated += OnValueUpdated;
|
||||
await notifyChar.StartUpdatesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_device = device;
|
||||
_notifyChar = notifyChar;
|
||||
_writeChar = writeChar;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { await _adapter.DisconnectDeviceAsync(device).ConfigureAwait(false); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes an ASCII string followed by CR+LF to the device.</summary>
|
||||
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.");
|
||||
await writeChar.WriteAsync(Encoding.ASCII.GetBytes(text), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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;
|
||||
if (bytes is null || bytes.Length == 0)
|
||||
return;
|
||||
|
||||
List<string> lines = new();
|
||||
lock (_rxBuffer)
|
||||
{
|
||||
_rxBuffer.Append(Encoding.ASCII.GetString(bytes));
|
||||
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);
|
||||
}
|
||||
_rxBuffer.Clear();
|
||||
_rxBuffer.Append(buffered);
|
||||
}
|
||||
|
||||
foreach (var line in lines)
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user