From 436633568ca085ec533df1fd73670c2239bf26dc Mon Sep 17 00:00:00 2001 From: Brent Perteet Date: Fri, 10 Jul 2026 20:48:42 -0500 Subject: [PATCH] Fix macOS Bluetooth state and add RTK debug console --- FieldLogger/Services/Ble/BleScanner.cs | 64 ++++++++++++++++++++++-- FieldLogger/Services/MaglinkService.cs | 4 ++ FieldLogger/ViewModels/DebugViewModel.cs | 63 +++++++++++++++++------ FieldLogger/Views/DebugPage.xaml | 4 +- 4 files changed, 116 insertions(+), 19 deletions(-) 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 @@ -