diff --git a/FieldLogger/Services/Ble/BleScanner.cs b/FieldLogger/Services/Ble/BleScanner.cs
index 60eb56a..b81e895 100644
--- a/FieldLogger/Services/Ble/BleScanner.cs
+++ b/FieldLogger/Services/Ble/BleScanner.cs
@@ -10,6 +10,7 @@ public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi);
/// Thin scan wrapper over Plugin.BLE with per-platform permission handling.
public sealed class BleScanner
{
+ private readonly IBluetoothLE _bluetooth = CrossBluetoothLE.Current;
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
public event EventHandler? DeviceDiscovered;
@@ -23,9 +24,7 @@ public sealed class BleScanner
public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
{
await EnsurePermissionsAsync();
-
- if (!CrossBluetoothLE.Current.IsOn)
- throw new InvalidOperationException("Bluetooth is turned off.");
+ await EnsureBluetoothReadyAsync(cancellationToken);
void OnDiscovered(object? sender, DeviceEventArgs e)
{
@@ -58,6 +57,65 @@ public sealed class BleScanner
}
}
+ private async Task EnsureBluetoothReadyAsync(CancellationToken cancellationToken)
+ {
+ // CoreBluetooth commonly starts in Unknown while CBCentralManager is being
+ // initialized. IsOn is false for every state except On, so checking it
+ // immediately incorrectly reports that Bluetooth is switched off on macOS.
+ if (_bluetooth.State is BluetoothState.Unknown or BluetoothState.TurningOn)
+ {
+ var stateChanged = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnStateChanged(object? sender, BluetoothStateChangedArgs args)
+ {
+ if (args.NewState is not (BluetoothState.Unknown or BluetoothState.TurningOn))
+ stateChanged.TrySetResult(args.NewState);
+ }
+
+ _bluetooth.StateChanged += OnStateChanged;
+ try
+ {
+ // Recheck after subscribing so a transition cannot be missed.
+ var currentState = _bluetooth.State;
+ if (currentState is BluetoothState.Unknown or BluetoothState.TurningOn)
+ {
+ using var initializationTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, initializationTimeout.Token);
+ try
+ {
+ await stateChanged.Task.WaitAsync(linkedCancellation.Token);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new InvalidOperationException(
+ "Bluetooth did not finish initializing. Try scanning again.");
+ }
+ }
+ }
+ finally
+ {
+ _bluetooth.StateChanged -= OnStateChanged;
+ }
+ }
+
+ if (_bluetooth.State == BluetoothState.On)
+ return;
+
+ throw _bluetooth.State switch
+ {
+ BluetoothState.Off or BluetoothState.TurningOff =>
+ new InvalidOperationException("Bluetooth is turned off."),
+ BluetoothState.Unauthorized =>
+ new PermissionException(
+ "Bluetooth access is denied. Enable Field Logger in System Settings > Privacy & Security > Bluetooth."),
+ BluetoothState.Unavailable =>
+ new InvalidOperationException("Bluetooth is not available on this Mac."),
+ _ => new InvalidOperationException($"Bluetooth is not ready (state: {_bluetooth.State}).")
+ };
+ }
+
/// Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).
public static async Task EnsurePermissionsAsync()
{
diff --git a/FieldLogger/Services/MaglinkService.cs b/FieldLogger/Services/MaglinkService.cs
index d254be1..42d0565 100644
--- a/FieldLogger/Services/MaglinkService.cs
+++ b/FieldLogger/Services/MaglinkService.cs
@@ -24,6 +24,8 @@ public sealed class MaglinkService : IAsyncDisposable
public event EventHandler? FixReceived;
public event EventHandler? DeviceInfoReceived;
+ /// Every complete line received from the RTK receiver, before validation or parsing.
+ public event EventHandler? LineReceived;
public event EventHandler? Disconnected;
/// Device-name filter for scan results (ML-*).
@@ -62,6 +64,8 @@ public sealed class MaglinkService : IAsyncDisposable
private void OnLineReceived(object? sender, string line)
{
+ LineReceived?.Invoke(this, line);
+
if (line.StartsWith("$GNPOS,", StringComparison.Ordinal))
{
if (!NmeaSentence.VerifyChecksum(line))
diff --git a/FieldLogger/ViewModels/DebugViewModel.cs b/FieldLogger/ViewModels/DebugViewModel.cs
index e19cbec..128aeed 100644
--- a/FieldLogger/ViewModels/DebugViewModel.cs
+++ b/FieldLogger/ViewModels/DebugViewModel.cs
@@ -9,6 +9,7 @@ namespace FieldLogger.ViewModels;
public sealed partial class DebugViewModel : ObservableObject
{
private readonly UmReceiverService _receiver;
+ private readonly MaglinkService _gps;
private readonly DeviceConnectionManager _manager;
[ObservableProperty]
@@ -46,32 +47,35 @@ public sealed partial class DebugViewModel : ObservableObject
{
_receiver = receiver;
_manager = manager;
+ _gps = manager.Gps;
}
public void OnAppearing()
{
- _receiver.LineReceived += OnLineReceived;
+ _receiver.LineReceived += OnUmLineReceived;
+ _gps.LineReceived += OnGpsLineReceived;
UpdateConnectionStatus();
}
public void OnDisappearing()
{
- _receiver.LineReceived -= OnLineReceived;
+ _receiver.LineReceived -= OnUmLineReceived;
+ _gps.LineReceived -= OnGpsLineReceived;
}
private void UpdateConnectionStatus()
{
IsConnected = _receiver.IsConnected;
- if (IsConnected)
- {
- ConnectionStatus = $"Connected to {_receiver.DeviceName ?? "receiver"}";
- ConnectionStateColor = "Green";
- }
- else
- {
- ConnectionStatus = "Not connected";
- ConnectionStateColor = "Gray";
- }
+ var connections = new List();
+ if (_receiver.IsConnected)
+ connections.Add($"Locator: {_receiver.DeviceName ?? "receiver"}");
+ if (_gps.IsConnected)
+ connections.Add($"RTK: {_gps.DeviceName ?? "receiver"}");
+
+ ConnectionStatus = connections.Count > 0
+ ? string.Join(" · ", connections)
+ : "No devices connected";
+ ConnectionStateColor = connections.Count > 0 ? "Green" : "Gray";
}
[RelayCommand]
@@ -133,16 +137,29 @@ public sealed partial class DebugViewModel : ObservableObject
}
}
- private void OnLineReceived(object? sender, string line)
+ private void OnUmLineReceived(object? sender, string line) => AddReceivedLine(line, false);
+
+ private void OnGpsLineReceived(object? sender, string line) => AddReceivedLine(line, true);
+
+ private void AddReceivedLine(string line, bool isGps)
{
MainThread.BeginInvokeOnMainThread(() =>
{
- var messageType = ClassifyMessage(line);
+ var messageType = isGps ? ClassifyGpsMessage(line) : ClassifyMessage(line);
var parsedData = ParseMessage(line, messageType);
AddMessage("RX", line, messageType, parsedData);
});
}
+ private static string ClassifyGpsMessage(string line)
+ {
+ if (line.StartsWith("$GNPOS,", StringComparison.Ordinal))
+ return NmeaSentence.VerifyChecksum(line) ? "GNPOS" : "GNPOS (bad checksum)";
+ if (line.StartsWith("$GNDEV,", StringComparison.Ordinal))
+ return NmeaSentence.VerifyChecksum(line) ? "GNDEV" : "GNDEV (bad checksum)";
+ return line.StartsWith('$') ? "NMEA" : "RTK Text";
+ }
+
private void AddMessage(string direction, string rawMessage, string type, string? parsedData = null)
{
var message = new DebugMessage
@@ -212,6 +229,19 @@ public sealed partial class DebugViewModel : ObservableObject
return $"Mfr={info.Manufacturer}, Model={info.ModelName}, SN={info.SerialNumber}, FW={info.SoftwareVersion}";
}
}
+ else if (type == "GNPOS")
+ {
+ var fix = GnssFix.TryParse(line);
+ if (fix is not null)
+ return $"{fix.StatusLabel}, {fix.Latitude:F8}, {fix.Longitude:F8}, " +
+ $"H ±{fix.Hrms:F3}m, V ±{fix.Vrms:F3}m, {fix.SatellitesUsed} sats";
+ }
+ else if (type == "GNDEV")
+ {
+ var info = GnssDeviceInfo.TryParse(line);
+ if (info is not null)
+ return $"SN={info.SerialNumber}, PCB={info.PcbVersion}, FW={info.FirmwareVersion}";
+ }
return null;
}
@@ -239,6 +269,11 @@ public sealed partial class DebugViewModel : ObservableObject
"UMLOG (Button)" => "Blue",
"UMLOG (Timed)" => "DodgerBlue",
"UMDEV" => "Purple",
+ "GNPOS" => "ForestGreen",
+ "GNDEV" => "DarkViolet",
+ _ when type.Contains("bad checksum", StringComparison.Ordinal) => "Red",
+ "NMEA" => "Teal",
+ "RTK Text" => "DarkOrange",
"Command" => "Orange",
_ when type.StartsWith("UMLOG") => "Blue",
_ => "Gray"
diff --git a/FieldLogger/Views/DebugPage.xaml b/FieldLogger/Views/DebugPage.xaml
index ae24bde..5a5c2a9 100644
--- a/FieldLogger/Views/DebugPage.xaml
+++ b/FieldLogger/Views/DebugPage.xaml
@@ -42,7 +42,7 @@
-
+
@@ -57,7 +57,7 @@
-