Files
ulapp/FieldLogger/Services/MaglinkService.cs
brentperteet b602b762c9 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>
2026-07-06 13:46:50 -05:00

99 lines
3.7 KiB
C#

using FieldLogger.Models;
using FieldLogger.Services.Ble;
using Microsoft.Extensions.Logging;
namespace FieldLogger.Services;
/// <summary>
/// Manages the connection to the Maglink (H11) RTK GNSS receiver: parses the
/// $GNPOS / $GNDEV custom NMEA stream and sends AT configuration commands.
/// </summary>
public sealed class MaglinkService : IAsyncDisposable
{
public static readonly Guid ServiceUuid = Guid.Parse("0000fff0-0000-1000-8000-00805f9b34fb");
public static readonly Guid NotifyUuid = Guid.Parse("0000fff2-0000-1000-8000-00805f9b34fb");
public static readonly Guid WriteUuid = Guid.Parse("0000fff2-0000-1000-8000-00805f9b34fb");
private readonly BleSerialClient _client = new();
private readonly ILogger<MaglinkService> _logger;
public GnssFix? LatestFix { get; private set; }
public GnssDeviceInfo? DeviceInfo { get; private set; }
public bool IsConnected => _client.IsConnected;
public string? DeviceName => _client.DeviceName;
public event EventHandler<GnssFix>? FixReceived;
public event EventHandler<GnssDeviceInfo>? DeviceInfoReceived;
public event EventHandler? Disconnected;
/// <summary>Device-name filter for scan results (ML-*).</summary>
public static bool IsMaglinkName(string? name) =>
name is not null && name.StartsWith("ML-", StringComparison.OrdinalIgnoreCase);
public MaglinkService(ILogger<MaglinkService> logger)
{
_logger = logger;
_client.LineReceived += OnLineReceived;
_client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty);
}
public async Task ConnectAsync(Guid deviceId, CancellationToken cancellationToken = default)
{
await _client.ConnectAsync(deviceId, ServiceUuid, NotifyUuid, WriteUuid, cancellationToken);
// Custom mode with GNPOS + GNDEV only - everything the app needs, minimal traffic.
await SendCommandAsync("AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0", cancellationToken);
}
/// <summary>Sends an AT command (terminator appended automatically).</summary>
public Task SendCommandAsync(string command, CancellationToken cancellationToken = default)
=> _client.WriteLineAsync(command, cancellationToken);
public Task DisconnectAsync() => _client.DisconnectAsync();
/// <summary>A fix received within the last few seconds, or null if the stream has gone stale.</summary>
public GnssFix? FreshFix(TimeSpan? maxAge = null)
{
var fix = LatestFix;
if (fix is null)
return null;
return DateTime.UtcNow - fix.ReceivedUtc <= (maxAge ?? TimeSpan.FromSeconds(5)) ? fix : null;
}
private void OnLineReceived(object? sender, string line)
{
if (line.StartsWith("$GNPOS,", StringComparison.Ordinal))
{
if (!NmeaSentence.VerifyChecksum(line))
{
_logger.LogWarning("GNPOS checksum failed: {Line}", line);
return;
}
var fix = GnssFix.TryParse(line);
if (fix is not null)
{
LatestFix = fix;
FixReceived?.Invoke(this, fix);
}
}
else if (line.StartsWith("$GNDEV,", StringComparison.Ordinal))
{
if (!NmeaSentence.VerifyChecksum(line))
return;
var info = GnssDeviceInfo.TryParse(line);
if (info is not null)
{
DeviceInfo = info;
DeviceInfoReceived?.Invoke(this, info);
}
}
else
{
// AT command responses and anything else.
_logger.LogDebug("Maglink rx: {Line}", line);
}
}
public ValueTask DisposeAsync() => _client.DisposeAsync();
}