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);
|
||||
}
|
||||
}
|
||||
90
FieldLogger/Services/Data/AppDatabase.cs
Normal file
90
FieldLogger/Services/Data/AppDatabase.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using FieldLogger.Models;
|
||||
using SQLite;
|
||||
|
||||
namespace FieldLogger.Services.Data;
|
||||
|
||||
/// <summary>Local SQLite store for jobs and logged points.</summary>
|
||||
public sealed class AppDatabase
|
||||
{
|
||||
private readonly Lazy<Task<SQLiteAsyncConnection>> _connection;
|
||||
|
||||
public AppDatabase()
|
||||
{
|
||||
_connection = new Lazy<Task<SQLiteAsyncConnection>>(async () =>
|
||||
{
|
||||
var path = Path.Combine(FileSystem.AppDataDirectory, "fieldlogger.db3");
|
||||
var db = new SQLiteAsyncConnection(path,
|
||||
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
|
||||
await db.CreateTableAsync<Job>();
|
||||
await db.CreateTableAsync<LoggedPoint>();
|
||||
return db;
|
||||
});
|
||||
}
|
||||
|
||||
private Task<SQLiteAsyncConnection> Db => _connection.Value;
|
||||
|
||||
// ---- Jobs ----
|
||||
|
||||
public async Task<List<Job>> GetJobsAsync()
|
||||
{
|
||||
var db = await Db;
|
||||
return await db.Table<Job>().OrderByDescending(j => j.CreatedUtc).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Job?> GetJobAsync(int id)
|
||||
{
|
||||
var db = await Db;
|
||||
return await db.Table<Job>().Where(j => j.Id == id).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<Job> CreateJobAsync(string name, string notes = "")
|
||||
{
|
||||
var db = await Db;
|
||||
var job = new Job { Name = name, Notes = notes };
|
||||
await db.InsertAsync(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
public async Task UpdateJobAsync(Job job)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.UpdateAsync(job);
|
||||
}
|
||||
|
||||
public async Task DeleteJobAsync(int jobId)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.Table<LoggedPoint>().DeleteAsync(p => p.JobId == jobId);
|
||||
await db.DeleteAsync<Job>(jobId);
|
||||
}
|
||||
|
||||
// ---- Points ----
|
||||
|
||||
public async Task<int> AddPointAsync(LoggedPoint point)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.InsertAsync(point);
|
||||
return point.Id;
|
||||
}
|
||||
|
||||
public async Task<List<LoggedPoint>> GetPointsAsync(int jobId)
|
||||
{
|
||||
var db = await Db;
|
||||
return await db.Table<LoggedPoint>()
|
||||
.Where(p => p.JobId == jobId)
|
||||
.OrderBy(p => p.TimestampUtc)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<int> GetPointCountAsync(int jobId)
|
||||
{
|
||||
var db = await Db;
|
||||
return await db.Table<LoggedPoint>().Where(p => p.JobId == jobId).CountAsync();
|
||||
}
|
||||
|
||||
public async Task DeletePointAsync(int pointId)
|
||||
{
|
||||
var db = await Db;
|
||||
await db.DeleteAsync<LoggedPoint>(pointId);
|
||||
}
|
||||
}
|
||||
175
FieldLogger/Services/DeviceConnectionManager.cs
Normal file
175
FieldLogger/Services/DeviceConnectionManager.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services.Ble;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FieldLogger.Services;
|
||||
|
||||
public enum ConnectionState { Disconnected, Connecting, Connected }
|
||||
|
||||
/// <summary>
|
||||
/// Owns the lifetime of both device connections: auto-connects to saved devices
|
||||
/// at startup, reconnects with backoff when a connection drops, and exposes
|
||||
/// bindable state for the UI.
|
||||
/// </summary>
|
||||
public sealed partial class DeviceConnectionManager : ObservableObject
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)];
|
||||
|
||||
private readonly UmReceiverService _locator;
|
||||
private readonly MaglinkService _gps;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ILogger<DeviceConnectionManager> _logger;
|
||||
|
||||
private CancellationTokenSource? _locatorCts;
|
||||
private CancellationTokenSource? _gpsCts;
|
||||
|
||||
[ObservableProperty]
|
||||
private ConnectionState _locatorState;
|
||||
|
||||
[ObservableProperty]
|
||||
private ConnectionState _gpsState;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _locatorName;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _gpsName;
|
||||
|
||||
public UmReceiverService Locator => _locator;
|
||||
public MaglinkService Gps => _gps;
|
||||
|
||||
public DeviceConnectionManager(UmReceiverService locator, MaglinkService gps,
|
||||
SettingsService settings, ILogger<DeviceConnectionManager> logger)
|
||||
{
|
||||
_locator = locator;
|
||||
_gps = gps;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
|
||||
_locator.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.Locator);
|
||||
_gps.Disconnected += (_, _) => OnDeviceDropped(DeviceKind.RtkGps);
|
||||
|
||||
LocatorName = settings.GetSavedDeviceName(DeviceKind.Locator);
|
||||
GpsName = settings.GetSavedDeviceName(DeviceKind.RtkGps);
|
||||
}
|
||||
|
||||
/// <summary>True when a device of this kind has been selected at least once.</summary>
|
||||
public bool HasSavedDevice(DeviceKind kind) => _settings.GetSavedDeviceId(kind) is not null;
|
||||
|
||||
/// <summary>Kicks off auto-connect attempts for every saved device. Safe to call repeatedly.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (HasSavedDevice(DeviceKind.Locator) && LocatorState == ConnectionState.Disconnected)
|
||||
_ = ConnectLoopAsync(DeviceKind.Locator);
|
||||
if (HasSavedDevice(DeviceKind.RtkGps) && GpsState == ConnectionState.Disconnected)
|
||||
_ = ConnectLoopAsync(DeviceKind.RtkGps);
|
||||
}
|
||||
|
||||
/// <summary>Saves a newly selected device and connects to it, replacing any previous device.</summary>
|
||||
public async Task UseDeviceAsync(DeviceKind kind, Guid id, string name)
|
||||
{
|
||||
CancelLoop(kind);
|
||||
await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync());
|
||||
|
||||
_settings.SaveDevice(kind, id, name);
|
||||
if (kind == DeviceKind.Locator)
|
||||
LocatorName = name;
|
||||
else
|
||||
GpsName = name;
|
||||
|
||||
_ = ConnectLoopAsync(kind);
|
||||
}
|
||||
|
||||
public async Task ForgetDeviceAsync(DeviceKind kind)
|
||||
{
|
||||
CancelLoop(kind);
|
||||
await (kind == DeviceKind.Locator ? _locator.DisconnectAsync() : _gps.DisconnectAsync());
|
||||
_settings.ClearDevice(kind);
|
||||
|
||||
if (kind == DeviceKind.Locator)
|
||||
{
|
||||
LocatorName = null;
|
||||
LocatorState = ConnectionState.Disconnected;
|
||||
}
|
||||
else
|
||||
{
|
||||
GpsName = null;
|
||||
GpsState = ConnectionState.Disconnected;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeviceDropped(DeviceKind kind)
|
||||
{
|
||||
_logger.LogInformation("{Kind} connection lost; scheduling reconnect", kind);
|
||||
SetState(kind, ConnectionState.Disconnected);
|
||||
_ = ConnectLoopAsync(kind);
|
||||
}
|
||||
|
||||
private async Task ConnectLoopAsync(DeviceKind kind)
|
||||
{
|
||||
var id = _settings.GetSavedDeviceId(kind);
|
||||
if (id is null)
|
||||
return;
|
||||
|
||||
CancelLoop(kind);
|
||||
var cts = new CancellationTokenSource();
|
||||
if (kind == DeviceKind.Locator)
|
||||
_locatorCts = cts;
|
||||
else
|
||||
_gpsCts = cts;
|
||||
|
||||
SetState(kind, ConnectionState.Connecting);
|
||||
|
||||
for (var attempt = 0; !cts.Token.IsCancellationRequested; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await BleScanner.EnsurePermissionsAsync();
|
||||
if (kind == DeviceKind.Locator)
|
||||
await _locator.ConnectAsync(id.Value, cts.Token);
|
||||
else
|
||||
await _gps.ConnectAsync(id.Value, cts.Token);
|
||||
|
||||
SetState(kind, ConnectionState.Connected);
|
||||
_logger.LogInformation("{Kind} connected", kind);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Kind} connect attempt {Attempt} failed", kind, attempt + 1);
|
||||
var delay = RetryDelays[Math.Min(attempt, RetryDelays.Length - 1)];
|
||||
try { await Task.Delay(delay, cts.Token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelLoop(DeviceKind kind)
|
||||
{
|
||||
var cts = kind == DeviceKind.Locator ? _locatorCts : _gpsCts;
|
||||
cts?.Cancel();
|
||||
cts?.Dispose();
|
||||
if (kind == DeviceKind.Locator)
|
||||
_locatorCts = null;
|
||||
else
|
||||
_gpsCts = null;
|
||||
}
|
||||
|
||||
private void SetState(DeviceKind kind, ConnectionState state)
|
||||
{
|
||||
// Property change notifications must fire on the UI thread for bindings.
|
||||
MainThread.BeginInvokeOnMainThread(() =>
|
||||
{
|
||||
if (kind == DeviceKind.Locator)
|
||||
LocatorState = state;
|
||||
else
|
||||
GpsState = state;
|
||||
});
|
||||
}
|
||||
}
|
||||
98
FieldLogger/Services/MaglinkService.cs
Normal file
98
FieldLogger/Services/MaglinkService.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
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();
|
||||
}
|
||||
67
FieldLogger/Services/PointLogger.cs
Normal file
67
FieldLogger/Services/PointLogger.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services.Data;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FieldLogger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Joins UM receiver log packets with the current GNSS fix and persists them
|
||||
/// under the active job. Runs for the lifetime of the app.
|
||||
/// </summary>
|
||||
public sealed class PointLogger
|
||||
{
|
||||
private readonly UmReceiverService _locator;
|
||||
private readonly MaglinkService _gps;
|
||||
private readonly AppDatabase _database;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ILogger<PointLogger> _logger;
|
||||
|
||||
/// <summary>Raised (on the UI thread) after a point is saved.</summary>
|
||||
public event EventHandler<LoggedPoint>? PointSaved;
|
||||
|
||||
/// <summary>Raised when a packet arrives but no active job is selected, so the point was dropped.</summary>
|
||||
public event EventHandler? PacketIgnoredNoJob;
|
||||
|
||||
public PointLogger(UmReceiverService locator, MaglinkService gps, AppDatabase database,
|
||||
SettingsService settings, ILogger<PointLogger> logger)
|
||||
{
|
||||
_locator = locator;
|
||||
_gps = gps;
|
||||
_database = database;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
|
||||
_locator.PacketReceived += OnPacketReceived;
|
||||
}
|
||||
|
||||
private void OnPacketReceived(object? sender, UmLogPacket packet)
|
||||
{
|
||||
_ = HandlePacketAsync(packet);
|
||||
}
|
||||
|
||||
private async Task HandlePacketAsync(UmLogPacket packet)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jobId = _settings.ActiveJobId;
|
||||
if (jobId is null)
|
||||
{
|
||||
_logger.LogWarning("Log packet received but no active job; point dropped");
|
||||
MainThread.BeginInvokeOnMainThread(() => PacketIgnoredNoJob?.Invoke(this, EventArgs.Empty));
|
||||
return;
|
||||
}
|
||||
|
||||
var fix = _gps.FreshFix();
|
||||
var point = LoggedPoint.From(jobId.Value, packet, _locator.DeviceInfo, fix, _gps.DeviceInfo);
|
||||
await _database.AddPointAsync(point);
|
||||
|
||||
_logger.LogInformation("Point {Id} saved to job {JobId} (gps valid: {GpsValid})",
|
||||
point.Id, jobId, point.GpsValid);
|
||||
MainThread.BeginInvokeOnMainThread(() => PointSaved?.Invoke(this, point));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save logged point");
|
||||
}
|
||||
}
|
||||
}
|
||||
58
FieldLogger/Services/SettingsService.cs
Normal file
58
FieldLogger/Services/SettingsService.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using FieldLogger.Models;
|
||||
|
||||
namespace FieldLogger.Services;
|
||||
|
||||
/// <summary>Persisted app settings: paired device identities, active job, map key.</summary>
|
||||
public sealed class SettingsService
|
||||
{
|
||||
private const string LocatorIdKey = "device.locator.id";
|
||||
private const string LocatorNameKey = "device.locator.name";
|
||||
private const string RtkIdKey = "device.rtk.id";
|
||||
private const string RtkNameKey = "device.rtk.name";
|
||||
private const string ActiveJobKey = "job.active.id";
|
||||
private const string MapsApiKeyKey = "maps.apikey";
|
||||
|
||||
public Guid? GetSavedDeviceId(DeviceKind kind)
|
||||
{
|
||||
var raw = Preferences.Default.Get(IdKey(kind), string.Empty);
|
||||
return Guid.TryParse(raw, out var id) ? id : null;
|
||||
}
|
||||
|
||||
public string? GetSavedDeviceName(DeviceKind kind)
|
||||
{
|
||||
var name = Preferences.Default.Get(NameKey(kind), string.Empty);
|
||||
return string.IsNullOrEmpty(name) ? null : name;
|
||||
}
|
||||
|
||||
public void SaveDevice(DeviceKind kind, Guid id, string name)
|
||||
{
|
||||
Preferences.Default.Set(IdKey(kind), id.ToString());
|
||||
Preferences.Default.Set(NameKey(kind), name);
|
||||
}
|
||||
|
||||
public void ClearDevice(DeviceKind kind)
|
||||
{
|
||||
Preferences.Default.Remove(IdKey(kind));
|
||||
Preferences.Default.Remove(NameKey(kind));
|
||||
}
|
||||
|
||||
public int? ActiveJobId
|
||||
{
|
||||
get
|
||||
{
|
||||
var id = Preferences.Default.Get(ActiveJobKey, 0);
|
||||
return id > 0 ? id : null;
|
||||
}
|
||||
set => Preferences.Default.Set(ActiveJobKey, value ?? 0);
|
||||
}
|
||||
|
||||
/// <summary>Google Maps JavaScript API key used by the Windows WebView map.</summary>
|
||||
public string GoogleMapsApiKey
|
||||
{
|
||||
get => Preferences.Default.Get(MapsApiKeyKey, string.Empty);
|
||||
set => Preferences.Default.Set(MapsApiKeyKey, value);
|
||||
}
|
||||
|
||||
private static string IdKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorIdKey : RtkIdKey;
|
||||
private static string NameKey(DeviceKind kind) => kind == DeviceKind.Locator ? LocatorNameKey : RtkNameKey;
|
||||
}
|
||||
27
FieldLogger/Services/Sync/IMqttSyncService.cs
Normal file
27
FieldLogger/Services/Sync/IMqttSyncService.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using FieldLogger.Models;
|
||||
|
||||
namespace FieldLogger.Services.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for the future MQTT synchronization layer:
|
||||
/// - subscribe to receive jobs configured on the server
|
||||
/// - publish logged points as they are captured
|
||||
/// - reconcile the Synced flags on <see cref="Job"/> and <see cref="LoggedPoint"/>
|
||||
/// Planned implementation: MQTTnet client against the configured broker.
|
||||
/// </summary>
|
||||
public interface IMqttSyncService
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
Task DisconnectAsync();
|
||||
Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>No-op stand-in until the MQTT backend exists.</summary>
|
||||
public sealed class NullMqttSyncService : IMqttSyncService
|
||||
{
|
||||
public bool IsConnected => false;
|
||||
public Task ConnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task DisconnectAsync() => Task.CompletedTask;
|
||||
public Task PublishPointAsync(LoggedPoint point, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
88
FieldLogger/Services/UmReceiverService.cs
Normal file
88
FieldLogger/Services/UmReceiverService.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using FieldLogger.Models;
|
||||
using FieldLogger.Services.Ble;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FieldLogger.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the connection to an Underground Magnetics locating receiver and
|
||||
/// its push-button data-logging (PBDL) protocol.
|
||||
/// </summary>
|
||||
public sealed class UmReceiverService : IAsyncDisposable
|
||||
{
|
||||
// UM Receiver BLE External Logging API v1.2:
|
||||
// serial port service with notify (RX) and write-no-response (TX) characteristics.
|
||||
public static readonly Guid ServiceUuid = Guid.Parse("554d0000-261f-677e-a6f1-54c57aa996d4");
|
||||
public static readonly Guid NotifyUuid = Guid.Parse("554d0001-261f-677e-a6f1-54c57aa996d4");
|
||||
public static readonly Guid WriteUuid = Guid.Parse("554d0002-261f-677e-a6f1-54c57aa996d4");
|
||||
|
||||
private readonly BleSerialClient _client = new();
|
||||
private readonly ILogger<UmReceiverService> _logger;
|
||||
|
||||
public UmDeviceInfo? DeviceInfo { get; private set; }
|
||||
public bool IsConnected => _client.IsConnected;
|
||||
public string? DeviceName => _client.DeviceName;
|
||||
|
||||
/// <summary>Raised when the operator presses the log button on the receiver.</summary>
|
||||
public event EventHandler<UmLogPacket>? PacketReceived;
|
||||
public event EventHandler<UmDeviceInfo>? DeviceInfoReceived;
|
||||
public event EventHandler? Disconnected;
|
||||
|
||||
public UmReceiverService(ILogger<UmReceiverService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_client.LineReceived += OnLineReceived;
|
||||
_client.Disconnected += (_, _) => Disconnected?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Device-name filter for scan results (UMRX_* for most brands, DT100_* for Leica).</summary>
|
||||
public static bool IsUmReceiverName(string? name) =>
|
||||
name is not null &&
|
||||
(name.StartsWith("UMRX", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.StartsWith("DT100", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public async Task ConnectAsync(Guid deviceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _client.ConnectAsync(deviceId, ServiceUuid, NotifyUuid, WriteUuid, cancellationToken);
|
||||
await EnableLoggingAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Enables push-button data logging. The first enable triggers the info string.</summary>
|
||||
public Task EnableLoggingAsync(CancellationToken cancellationToken = default)
|
||||
=> _client.WriteLineAsync("$UMPBDL,1", cancellationToken);
|
||||
|
||||
public Task DisableLoggingAsync(CancellationToken cancellationToken = default)
|
||||
=> _client.WriteLineAsync("$UMPBDL,0", cancellationToken);
|
||||
|
||||
public Task DisconnectAsync() => _client.DisconnectAsync();
|
||||
|
||||
private void OnLineReceived(object? sender, string line)
|
||||
{
|
||||
_logger.LogDebug("UM rx: {Line}", line);
|
||||
|
||||
// Bare command acknowledgements.
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Equals("OK", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Equals("ERROR", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var packet = UmLogPacket.TryParse(line);
|
||||
if (packet is not null)
|
||||
{
|
||||
PacketReceived?.Invoke(this, packet);
|
||||
return;
|
||||
}
|
||||
|
||||
var info = UmDeviceInfo.TryParse(line);
|
||||
if (info is not null)
|
||||
{
|
||||
DeviceInfo = info;
|
||||
DeviceInfoReceived?.Invoke(this, info);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("UM receiver sent unrecognized line: {Line}", line);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => _client.DisposeAsync();
|
||||
}
|
||||
Reference in New Issue
Block a user