Fix macOS Bluetooth state and add RTK debug console
This commit is contained in:
@@ -10,6 +10,7 @@ public sealed record DiscoveredDevice(Guid Id, string Name, int Rssi);
|
|||||||
/// <summary>Thin scan wrapper over Plugin.BLE with per-platform permission handling.</summary>
|
/// <summary>Thin scan wrapper over Plugin.BLE with per-platform permission handling.</summary>
|
||||||
public sealed class BleScanner
|
public sealed class BleScanner
|
||||||
{
|
{
|
||||||
|
private readonly IBluetoothLE _bluetooth = CrossBluetoothLE.Current;
|
||||||
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
|
private readonly IAdapter _adapter = CrossBluetoothLE.Current.Adapter;
|
||||||
|
|
||||||
public event EventHandler<DiscoveredDevice>? DeviceDiscovered;
|
public event EventHandler<DiscoveredDevice>? DeviceDiscovered;
|
||||||
@@ -23,9 +24,7 @@ public sealed class BleScanner
|
|||||||
public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
|
public async Task ScanAsync(TimeSpan duration, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await EnsurePermissionsAsync();
|
await EnsurePermissionsAsync();
|
||||||
|
await EnsureBluetoothReadyAsync(cancellationToken);
|
||||||
if (!CrossBluetoothLE.Current.IsOn)
|
|
||||||
throw new InvalidOperationException("Bluetooth is turned off.");
|
|
||||||
|
|
||||||
void OnDiscovered(object? sender, DeviceEventArgs e)
|
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<BluetoothState>(
|
||||||
|
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}).")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).</summary>
|
/// <summary>Requests the runtime permissions BLE scanning needs (Android only; no-op elsewhere).</summary>
|
||||||
public static async Task EnsurePermissionsAsync()
|
public static async Task EnsurePermissionsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public sealed class MaglinkService : IAsyncDisposable
|
|||||||
|
|
||||||
public event EventHandler<GnssFix>? FixReceived;
|
public event EventHandler<GnssFix>? FixReceived;
|
||||||
public event EventHandler<GnssDeviceInfo>? DeviceInfoReceived;
|
public event EventHandler<GnssDeviceInfo>? DeviceInfoReceived;
|
||||||
|
/// <summary>Every complete line received from the RTK receiver, before validation or parsing.</summary>
|
||||||
|
public event EventHandler<string>? LineReceived;
|
||||||
public event EventHandler? Disconnected;
|
public event EventHandler? Disconnected;
|
||||||
|
|
||||||
/// <summary>Device-name filter for scan results (ML-*).</summary>
|
/// <summary>Device-name filter for scan results (ML-*).</summary>
|
||||||
@@ -62,6 +64,8 @@ public sealed class MaglinkService : IAsyncDisposable
|
|||||||
|
|
||||||
private void OnLineReceived(object? sender, string line)
|
private void OnLineReceived(object? sender, string line)
|
||||||
{
|
{
|
||||||
|
LineReceived?.Invoke(this, line);
|
||||||
|
|
||||||
if (line.StartsWith("$GNPOS,", StringComparison.Ordinal))
|
if (line.StartsWith("$GNPOS,", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
if (!NmeaSentence.VerifyChecksum(line))
|
if (!NmeaSentence.VerifyChecksum(line))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace FieldLogger.ViewModels;
|
|||||||
public sealed partial class DebugViewModel : ObservableObject
|
public sealed partial class DebugViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly UmReceiverService _receiver;
|
private readonly UmReceiverService _receiver;
|
||||||
|
private readonly MaglinkService _gps;
|
||||||
private readonly DeviceConnectionManager _manager;
|
private readonly DeviceConnectionManager _manager;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -46,32 +47,35 @@ public sealed partial class DebugViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
_receiver = receiver;
|
_receiver = receiver;
|
||||||
_manager = manager;
|
_manager = manager;
|
||||||
|
_gps = manager.Gps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnAppearing()
|
public void OnAppearing()
|
||||||
{
|
{
|
||||||
_receiver.LineReceived += OnLineReceived;
|
_receiver.LineReceived += OnUmLineReceived;
|
||||||
|
_gps.LineReceived += OnGpsLineReceived;
|
||||||
UpdateConnectionStatus();
|
UpdateConnectionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnDisappearing()
|
public void OnDisappearing()
|
||||||
{
|
{
|
||||||
_receiver.LineReceived -= OnLineReceived;
|
_receiver.LineReceived -= OnUmLineReceived;
|
||||||
|
_gps.LineReceived -= OnGpsLineReceived;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateConnectionStatus()
|
private void UpdateConnectionStatus()
|
||||||
{
|
{
|
||||||
IsConnected = _receiver.IsConnected;
|
IsConnected = _receiver.IsConnected;
|
||||||
if (IsConnected)
|
var connections = new List<string>();
|
||||||
{
|
if (_receiver.IsConnected)
|
||||||
ConnectionStatus = $"Connected to {_receiver.DeviceName ?? "receiver"}";
|
connections.Add($"Locator: {_receiver.DeviceName ?? "receiver"}");
|
||||||
ConnectionStateColor = "Green";
|
if (_gps.IsConnected)
|
||||||
}
|
connections.Add($"RTK: {_gps.DeviceName ?? "receiver"}");
|
||||||
else
|
|
||||||
{
|
ConnectionStatus = connections.Count > 0
|
||||||
ConnectionStatus = "Not connected";
|
? string.Join(" · ", connections)
|
||||||
ConnectionStateColor = "Gray";
|
: "No devices connected";
|
||||||
}
|
ConnectionStateColor = connections.Count > 0 ? "Green" : "Gray";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[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(() =>
|
MainThread.BeginInvokeOnMainThread(() =>
|
||||||
{
|
{
|
||||||
var messageType = ClassifyMessage(line);
|
var messageType = isGps ? ClassifyGpsMessage(line) : ClassifyMessage(line);
|
||||||
var parsedData = ParseMessage(line, messageType);
|
var parsedData = ParseMessage(line, messageType);
|
||||||
AddMessage("RX", line, messageType, parsedData);
|
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)
|
private void AddMessage(string direction, string rawMessage, string type, string? parsedData = null)
|
||||||
{
|
{
|
||||||
var message = new DebugMessage
|
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}";
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +269,11 @@ public sealed partial class DebugViewModel : ObservableObject
|
|||||||
"UMLOG (Button)" => "Blue",
|
"UMLOG (Button)" => "Blue",
|
||||||
"UMLOG (Timed)" => "DodgerBlue",
|
"UMLOG (Timed)" => "DodgerBlue",
|
||||||
"UMDEV" => "Purple",
|
"UMDEV" => "Purple",
|
||||||
|
"GNPOS" => "ForestGreen",
|
||||||
|
"GNDEV" => "DarkViolet",
|
||||||
|
_ when type.Contains("bad checksum", StringComparison.Ordinal) => "Red",
|
||||||
|
"NMEA" => "Teal",
|
||||||
|
"RTK Text" => "DarkOrange",
|
||||||
"Command" => "Orange",
|
"Command" => "Orange",
|
||||||
_ when type.StartsWith("UMLOG") => "Blue",
|
_ when type.StartsWith("UMLOG") => "Blue",
|
||||||
_ => "Gray"
|
_ => "Gray"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Label Text="Message Log" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
<Label Text="Device Console" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
|
||||||
|
|
||||||
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
|
||||||
<VerticalStackLayout Spacing="10">
|
<VerticalStackLayout Spacing="10">
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<CollectionView ItemsSource="{Binding Messages}" HeightRequest="400">
|
<CollectionView ItemsSource="{Binding Messages}" HeightRequest="400">
|
||||||
<CollectionView.EmptyView>
|
<CollectionView.EmptyView>
|
||||||
<Label Text="No messages yet. Messages will appear here when received from the device."
|
<Label Text="No messages yet. Locator data and RTK NMEA sentences will appear here as they are received."
|
||||||
TextColor="Gray" FontSize="12" HorizontalOptions="Center" VerticalOptions="Center" />
|
TextColor="Gray" FontSize="12" HorizontalOptions="Center" VerticalOptions="Center" />
|
||||||
</CollectionView.EmptyView>
|
</CollectionView.EmptyView>
|
||||||
<CollectionView.ItemTemplate>
|
<CollectionView.ItemTemplate>
|
||||||
|
|||||||
Reference in New Issue
Block a user