Add Debug page and fix BLE communication issues

Major changes:
- Added Debug tab with real-time message logging and hex viewer
- Implemented timed data logging ($UMTDL command) per API v1.3
- Fixed BLE message parsing to handle notifications without line endings
- Added Schema 2 support (22-field timed logging packets)
- Updated Maglink BLE UUIDs (fff0/fff1/fff2)
- Added connection timeout (10s) to prevent hanging on unavailable devices
- Added connection status display and manual push-button logging trigger
- Improved retry logging with attempt numbers and delays
- Added console window allocation for Windows debug output
- Added BLE disconnect on app close

Bug fixes:
- Fixed device name filtering for Maglink (ML-* prefix)
- Fixed RtkGps enum reference in DeviceScanViewModel
- Fixed DebugMessage namespace (moved to Models)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-07-06 17:32:00 -05:00
parent b602b762c9
commit 299ec3875d
19 changed files with 608 additions and 27 deletions

View File

@@ -2,7 +2,10 @@
"permissions": {
"allow": [
"Bash(git init)",
"Bash(git add .)"
"Bash(git add .)",
"Bash(git commit -m \"$(cat <<''EOF''\nInitial commit: FieldLogger MAUI app with Maglink BLE support\n\nAdded Maglink RTK GNSS receiver integration with correct BLE UUIDs and device name filtering (ML-* prefix).\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Read(//d/maglink/**)",
"Bash(git add -A)"
],
"deny": [],
"ask": []

View File

@@ -1,14 +1,57 @@
namespace FieldLogger;
using FieldLogger.Services;
namespace FieldLogger;
public partial class App : Application
{
public App()
private readonly DeviceConnectionManager _connectionManager;
public App(DeviceConnectionManager connectionManager)
{
InitializeComponent();
_connectionManager = connectionManager;
Console.WriteLine("===== FIELD LOGGER APP STARTING =====");
Console.WriteLine($"Console output is working! Time: {DateTime.Now:HH:mm:ss}");
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
var window = new Window(new AppShell());
// Disconnect BLE devices when window is closing
window.Destroying += async (s, e) =>
{
Console.WriteLine("App: Window closing, disconnecting devices...");
await DisconnectAllDevicesAsync();
};
return window;
}
private async Task DisconnectAllDevicesAsync()
{
try
{
// Disconnect locator
if (_connectionManager.Locator.IsConnected)
{
Console.WriteLine("App: Disconnecting locator...");
await _connectionManager.Locator.DisconnectAsync();
}
// Disconnect GPS
if (_connectionManager.Gps.IsConnected)
{
Console.WriteLine("App: Disconnecting GPS...");
await _connectionManager.Gps.DisconnectAsync();
}
Console.WriteLine("App: All devices disconnected");
}
catch (Exception ex)
{
Console.WriteLine($"App: Error disconnecting devices: {ex.Message}");
}
}
}

View File

@@ -10,6 +10,7 @@
<ShellContent Title="Home" Route="home" ContentTemplate="{DataTemplate views:HomePage}" />
<ShellContent Title="Jobs" Route="jobs" ContentTemplate="{DataTemplate views:JobsPage}" />
<ShellContent Title="Map" Route="map" ContentTemplate="{DataTemplate views:MapPage}" />
<ShellContent Title="Debug" Route="debug" ContentTemplate="{DataTemplate views:DebugPage}" />
<ShellContent Title="Settings" Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" />
</TabBar>

View File

@@ -42,6 +42,7 @@ public static class MauiProgram
builder.Services.AddSingleton<HomeViewModel>();
builder.Services.AddSingleton<JobsViewModel>();
builder.Services.AddSingleton<MapViewModel>();
builder.Services.AddSingleton<DebugViewModel>();
builder.Services.AddSingleton<SettingsViewModel>();
builder.Services.AddTransient<DeviceScanViewModel>();
builder.Services.AddTransient<JobDetailViewModel>();
@@ -50,6 +51,7 @@ public static class MauiProgram
builder.Services.AddSingleton<HomePage>();
builder.Services.AddSingleton<JobsPage>();
builder.Services.AddSingleton<MapPage>();
builder.Services.AddSingleton<DebugPage>();
builder.Services.AddSingleton<SettingsPage>();
builder.Services.AddTransient<DeviceScanPage>();
builder.Services.AddTransient<JobDetailPage>();

View File

@@ -0,0 +1,24 @@
namespace FieldLogger.Models;
public class DebugMessage
{
public DateTime Timestamp { get; set; }
public string Direction { get; set; } = "";
public string RawMessage { get; set; } = "";
public string Type { get; set; } = "";
public string? ParsedData { get; set; }
public string TypeColor { get; set; } = "Gray";
public bool HasParsedData => !string.IsNullOrEmpty(ParsedData);
public string HexBytes
{
get
{
if (string.IsNullOrEmpty(RawMessage))
return "[empty]";
var bytes = System.Text.Encoding.ASCII.GetBytes(RawMessage);
return BitConverter.ToString(bytes).Replace("-", " ");
}
}
}

View File

@@ -10,12 +10,16 @@ public enum UmUtility { None = 0, Gas = 1, Power = 2, Communications = 3, Water
public enum UmBatteryType { NotMeasured = 0, Alkaline = 1, AlkalineError = 2, Lithium = 3 }
public enum UmLogType { PushButton = 0, TimeBased = 1 }
/// <summary>
/// A single push-button data-log packet from the UM locating receiver
/// (PBDL schema 1, 21 comma-delimited fields).
/// A single data-log packet from the UM locating receiver.
/// Schema 1 (push-button): 21 fields
/// Schema 2 (timed logging): 22 fields with LogType as first field
/// </summary>
public sealed class UmLogPacket
{
public UmLogType? LogType { get; init; } // Schema 2 only
public UmMode Mode { get; init; }
public int Frequency { get; init; }
public UmFreqType FreqType { get; init; }
@@ -39,7 +43,8 @@ public sealed class UmLogPacket
public bool MenuInUse { get; init; }
public string RawText { get; init; } = "";
public const int FieldCount = 21;
public const int Schema1FieldCount = 21;
public const int Schema2FieldCount = 22;
/// <summary>Depth converted to meters, when the raw string can be interpreted; otherwise null.</summary>
public double? DepthMeters => TryParseDepthMeters(DepthRaw);
@@ -90,23 +95,45 @@ public sealed class UmLogPacket
}
/// <summary>
/// Parses a 21-field PBDL schema 1 packet. Returns null if the line does not look like a data packet.
/// Parses either Schema 1 (21 fields, push-button) or Schema 2 (22 fields, timed logging).
/// Returns null if the line does not look like a data packet.
/// </summary>
public static UmLogPacket? TryParse(string line)
{
var fields = line.Trim().Split(',');
if (fields.Length < FieldCount)
return null;
// First field must be a small integer (Mode 0-6) to qualify as a data packet.
if (!int.TryParse(fields[0], out var mode) || mode is < 0 or > 6)
return null;
// Try Schema 2 (22 fields with LogType)
if (fields.Length >= Schema2FieldCount)
{
if (int.TryParse(fields[0], out var logType) && logType is 0 or 1)
{
if (int.TryParse(fields[1], out var mode) && mode is >= 0 and <= 6)
{
return ParseSchema2(fields, (UmLogType)logType, line);
}
}
}
// Try Schema 1 (21 fields without LogType)
if (fields.Length >= Schema1FieldCount)
{
if (int.TryParse(fields[0], out var mode) && mode is >= 0 and <= 6)
{
return ParseSchema1(fields, line);
}
}
return null;
}
private static UmLogPacket? ParseSchema1(string[] fields, string line)
{
try
{
return new UmLogPacket
{
Mode = (UmMode)mode,
LogType = null, // Schema 1 doesn't have LogType
Mode = (UmMode)ParseInt(fields[0]),
Frequency = ParseInt(fields[1]),
FreqType = (UmFreqType)ParseInt(fields[2]),
Signal = ParseInt(fields[3]),
@@ -136,6 +163,44 @@ public sealed class UmLogPacket
}
}
private static UmLogPacket? ParseSchema2(string[] fields, UmLogType logType, string line)
{
try
{
// Schema 2 has LogType at index 0, then same fields as Schema 1 shifted by 1
return new UmLogPacket
{
LogType = logType,
Mode = (UmMode)ParseInt(fields[1]),
Frequency = ParseInt(fields[2]),
FreqType = (UmFreqType)ParseInt(fields[3]),
Signal = ParseInt(fields[4]),
GainDb = ParseInt(fields[5]),
DepthRaw = fields[6].Trim(),
CurrentRaw = fields[7].Trim(),
CompassAngle = ParseInt(fields[8]),
GuidanceArrows = ParseInt(fields[9]),
LdPhase = ParseInt(fields[10]),
Clipping = ParseInt(fields[11]) != 0,
DepthCurrentSetting = ParseInt(fields[12]),
LrArrowStyle = ParseInt(fields[13]),
AudioVolume = ParseInt(fields[14]),
AudioModulation = ParseInt(fields[15]),
AudioSound = ParseInt(fields[16]),
BatteryVoltage = ParseDouble(fields[17]),
BatteryType = (UmBatteryType)ParseInt(fields[18]),
Backlight = ParseInt(fields[19]),
Utility = (UmUtility)ParseInt(fields[20]),
MenuInUse = ParseInt(fields[21]) != 0,
RawText = line.Trim(),
};
}
catch (FormatException)
{
return null;
}
}
private static int ParseInt(string s) => int.Parse(s.Trim(), CultureInfo.InvariantCulture);
private static double ParseDouble(string s) => double.Parse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture);
}

View File

@@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true">
<!-- Google Maps API key: replace with your key from https://console.cloud.google.com (Maps SDK for Android) -->
<meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_GOOGLE_MAPS_ANDROID_API_KEY" />
<meta-data android:name="com.google.android.geo.API_KEY" android:value="AIzaSyDhH16gF-7UN-CBsTQGfQSHNGjLC6VJ5dI" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />

View File

@@ -1,4 +1,5 @@
using Microsoft.UI.Xaml;
using System.Runtime.InteropServices;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
@@ -10,6 +11,10 @@ namespace FieldLogger.WinUI;
/// </summary>
public partial class App : MauiWinUIApplication
{
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool AllocConsole();
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
@@ -17,6 +22,12 @@ public partial class App : MauiWinUIApplication
public App()
{
this.InitializeComponent();
// Allocate console window for debug output
#if DEBUG
AllocConsole();
Console.WriteLine("===== DEBUG CONSOLE ALLOCATED =====");
#endif
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();

View File

@@ -42,10 +42,14 @@ public sealed class BleSerialClient : IAsyncDisposable
{
await DisconnectAsync().ConfigureAwait(false);
// Add a timeout to prevent hanging indefinitely when device is not available
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(10));
var device = await _adapter.ConnectToKnownDeviceAsync(
deviceId,
new ConnectParameters(autoConnect: false, forceBleTransport: true),
cancellationToken).ConfigureAwait(false);
timeoutCts.Token).ConfigureAwait(false);
try
{
@@ -53,7 +57,7 @@ public sealed class BleSerialClient : IAsyncDisposable
try { await device.RequestMtuAsync(247).ConfigureAwait(false); }
catch { /* not supported on this platform */ }
var service = await device.GetServiceAsync(serviceUuid, cancellationToken).ConfigureAwait(false)
var service = await device.GetServiceAsync(serviceUuid, timeoutCts.Token).ConfigureAwait(false)
?? throw new InvalidOperationException($"Service {serviceUuid} not found on {device.Name}.");
var notifyChar = await service.GetCharacteristicAsync(notifyUuid).ConfigureAwait(false)
@@ -66,12 +70,16 @@ public sealed class BleSerialClient : IAsyncDisposable
? CharacteristicWriteType.WithoutResponse
: CharacteristicWriteType.WithResponse;
Console.WriteLine($"BLE: Subscribing to notifications on {notifyUuid}");
notifyChar.ValueUpdated += OnValueUpdated;
await notifyChar.StartUpdatesAsync(cancellationToken).ConfigureAwait(false);
await notifyChar.StartUpdatesAsync(timeoutCts.Token).ConfigureAwait(false);
Console.WriteLine($"BLE: Successfully subscribed to notifications");
_device = device;
_notifyChar = notifyChar;
_writeChar = writeChar;
Console.WriteLine($"BLE: Connection complete. Device={device.Name}, CanWrite={writeChar.CanWrite}, CanNotify={notifyChar.Properties.HasFlag(CharacteristicPropertyType.Notify)}");
}
catch
{
@@ -87,7 +95,10 @@ public sealed class BleSerialClient : IAsyncDisposable
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);
var bytes = Encoding.ASCII.GetBytes(text);
Console.WriteLine($"BLE TX: {text.Replace("\r", "\\r").Replace("\n", "\\n")} ({bytes.Length} bytes)");
await writeChar.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
Console.WriteLine("BLE TX: Write completed");
}
public async Task DisconnectAsync()
@@ -117,13 +128,18 @@ public sealed class BleSerialClient : IAsyncDisposable
private void OnValueUpdated(object? sender, CharacteristicUpdatedEventArgs e)
{
var bytes = e.Characteristic.Value;
Console.WriteLine($"BLE RX: {bytes?.Length ?? 0} bytes");
if (bytes is null || bytes.Length == 0)
return;
List<string> lines = new();
lock (_rxBuffer)
{
_rxBuffer.Append(Encoding.ASCII.GetString(bytes));
var text = Encoding.ASCII.GetString(bytes);
Console.WriteLine($"BLE RX decoded: {text.Replace("\r", "\\r").Replace("\n", "\\n")}");
_rxBuffer.Append(text);
var buffered = _rxBuffer.ToString();
int newline;
while ((newline = buffered.IndexOf('\n')) >= 0)
@@ -133,12 +149,25 @@ public sealed class BleSerialClient : IAsyncDisposable
if (line.Length > 0)
lines.Add(line);
}
// If no newlines found but we have data, treat each notification as a complete message
// This handles devices that send complete messages per notification without line endings
if (lines.Count == 0 && buffered.Length > 0)
{
lines.Add(buffered.Trim());
buffered = "";
}
_rxBuffer.Clear();
_rxBuffer.Append(buffered);
}
Console.WriteLine($"BLE RX: {lines.Count} complete lines");
foreach (var line in lines)
{
Console.WriteLine($"BLE RX line: {line}");
LineReceived?.Invoke(this, line);
}
}
private void OnDeviceDisconnected(object? sender, DeviceEventArgs e) => HandleDisconnect(e.Device);

View File

@@ -126,6 +126,7 @@ public sealed partial class DeviceConnectionManager : ObservableObject
{
try
{
_logger.LogInformation("{Kind} connection attempt {Attempt}", kind, attempt + 1);
await BleScanner.EnsurePermissionsAsync();
if (kind == DeviceKind.Locator)
await _locator.ConnectAsync(id.Value, cts.Token);
@@ -133,19 +134,25 @@ public sealed partial class DeviceConnectionManager : ObservableObject
await _gps.ConnectAsync(id.Value, cts.Token);
SetState(kind, ConnectionState.Connected);
_logger.LogInformation("{Kind} connected", kind);
_logger.LogInformation("{Kind} connected successfully", kind);
return;
}
catch (OperationCanceledException)
{
_logger.LogInformation("{Kind} connection cancelled", kind);
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Kind} connect attempt {Attempt} failed", kind, attempt + 1);
_logger.LogWarning(ex, "{Kind} connect attempt {Attempt} failed: {Message}", kind, attempt + 1, ex.Message);
var delay = RetryDelays[Math.Min(attempt, RetryDelays.Length - 1)];
_logger.LogInformation("{Kind} retrying in {Delay}s...", kind, delay.TotalSeconds);
try { await Task.Delay(delay, cts.Token); }
catch (OperationCanceledException) { return; }
catch (OperationCanceledException)
{
_logger.LogInformation("{Kind} retry cancelled", kind);
return;
}
}
}
}

View File

@@ -12,7 +12,7 @@ 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");
public static readonly Guid WriteUuid = Guid.Parse("0000fff1-0000-1000-8000-00805f9b34fb");
private readonly BleSerialClient _client = new();
private readonly ILogger<MaglinkService> _logger;

View File

@@ -10,7 +10,7 @@ namespace FieldLogger.Services;
/// </summary>
public sealed class UmReceiverService : IAsyncDisposable
{
// UM Receiver BLE External Logging API v1.2:
// UM Receiver BLE External Logging API v1.3:
// 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");
@@ -23,11 +23,14 @@ public sealed class UmReceiverService : IAsyncDisposable
public bool IsConnected => _client.IsConnected;
public string? DeviceName => _client.DeviceName;
/// <summary>Raised when the operator presses the log button on the receiver.</summary>
/// <summary>Raised when the operator presses the log button on the receiver or timed logging triggers.</summary>
public event EventHandler<UmLogPacket>? PacketReceived;
public event EventHandler<UmDeviceInfo>? DeviceInfoReceived;
public event EventHandler? Disconnected;
/// <summary>Raised for all received lines (for debugging).</summary>
public event EventHandler<string>? LineReceived;
public UmReceiverService(ILogger<UmReceiverService> logger)
{
_logger = logger;
@@ -54,12 +57,31 @@ public sealed class UmReceiverService : IAsyncDisposable
public Task DisableLoggingAsync(CancellationToken cancellationToken = default)
=> _client.WriteLineAsync("$UMPBDL,0", cancellationToken);
/// <summary>
/// Enables timed data logging at the specified period.
/// </summary>
/// <param name="logPeriod">Log period multiplier (period = logPeriod * 100ms). Valid: 10-100.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task EnableTimedLoggingAsync(int logPeriod, CancellationToken cancellationToken = default)
{
if (logPeriod < 10 || logPeriod > 100)
throw new ArgumentOutOfRangeException(nameof(logPeriod), "Log period must be between 10 and 100 (1s to 10s)");
return _client.WriteLineAsync($"$UMETDL,{logPeriod}", cancellationToken);
}
/// <summary>Disables timed data logging.</summary>
public Task DisableTimedLoggingAsync(CancellationToken cancellationToken = default)
=> _client.WriteLineAsync("$UMETDL,0", cancellationToken);
public Task DisconnectAsync() => _client.DisconnectAsync();
private void OnLineReceived(object? sender, string line)
{
_logger.LogDebug("UM rx: {Line}", line);
// Notify debug listeners of all lines
LineReceived?.Invoke(this, line);
// Bare command acknowledgements.
var trimmed = line.Trim();
if (trimmed.Equals("OK", StringComparison.OrdinalIgnoreCase) ||

View File

@@ -0,0 +1,254 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FieldLogger.Models;
using FieldLogger.Services;
namespace FieldLogger.ViewModels;
public sealed partial class DebugViewModel : ObservableObject
{
private readonly UmReceiverService _receiver;
private readonly DeviceConnectionManager _manager;
[ObservableProperty]
private bool _isTimedLoggingEnabled;
[ObservableProperty]
private int _logPeriod = 10; // 10 = 1000ms (10 * 100ms)
[ObservableProperty]
private string _timedLoggingStatus = "Disabled";
[ObservableProperty]
private bool _autoScroll = true;
[ObservableProperty]
private bool _showHex = false;
[ObservableProperty]
private int _messageCount;
[ObservableProperty]
private bool _isConnected;
[ObservableProperty]
private string _connectionStatus = "Not connected";
[ObservableProperty]
private string _connectionStateColor = "Gray";
public ObservableCollection<DebugMessage> Messages { get; } = new();
public string LogPeriodLabel => $"Period: {LogPeriod * 100}ms ({(LogPeriod * 100 / 1000.0):F1}s)";
public DebugViewModel(UmReceiverService receiver, DeviceConnectionManager manager)
{
_receiver = receiver;
_manager = manager;
}
public void OnAppearing()
{
_receiver.LineReceived += OnLineReceived;
UpdateConnectionStatus();
}
public void OnDisappearing()
{
_receiver.LineReceived -= OnLineReceived;
}
private void UpdateConnectionStatus()
{
IsConnected = _receiver.IsConnected;
if (IsConnected)
{
ConnectionStatus = $"Connected to {_receiver.DeviceName ?? "receiver"}";
ConnectionStateColor = "Green";
}
else
{
ConnectionStatus = "Not connected";
ConnectionStateColor = "Gray";
}
}
[RelayCommand]
private async Task EnablePushButtonLoggingAsync()
{
try
{
await _receiver.EnableLoggingAsync();
AddMessage("TX", "$UMPBDL,1", "Command");
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert("Error", $"Failed to enable logging: {ex.Message}", "OK");
}
}
partial void OnIsTimedLoggingEnabledChanged(bool value)
{
_ = UpdateTimedLoggingAsync(value);
}
partial void OnLogPeriodChanged(int value)
{
OnPropertyChanged(nameof(LogPeriodLabel));
if (IsTimedLoggingEnabled)
{
_ = UpdateTimedLoggingAsync(true);
}
}
private async Task UpdateTimedLoggingAsync(bool enabled)
{
try
{
if (!_receiver.IsConnected)
{
TimedLoggingStatus = "Not connected to receiver";
IsTimedLoggingEnabled = false;
return;
}
if (enabled)
{
await _receiver.EnableTimedLoggingAsync(LogPeriod);
TimedLoggingStatus = $"Enabled - {LogPeriod * 100}ms interval";
AddMessage("TX", $"$UMETDL,{LogPeriod}", "Command");
}
else
{
await _receiver.DisableTimedLoggingAsync();
TimedLoggingStatus = "Disabled";
AddMessage("TX", "$UMETDL,0", "Command");
}
}
catch (Exception ex)
{
TimedLoggingStatus = $"Error: {ex.Message}";
IsTimedLoggingEnabled = false;
}
}
private void OnLineReceived(object? sender, string line)
{
MainThread.BeginInvokeOnMainThread(() =>
{
var messageType = ClassifyMessage(line);
var parsedData = ParseMessage(line, messageType);
AddMessage("RX", line, messageType, parsedData);
});
}
private void AddMessage(string direction, string rawMessage, string type, string? parsedData = null)
{
var message = new DebugMessage
{
Timestamp = DateTime.Now,
Direction = direction,
RawMessage = rawMessage,
Type = type,
ParsedData = parsedData,
TypeColor = GetTypeColor(type)
};
Messages.Add(message);
MessageCount = Messages.Count;
// Keep last 500 messages
while (Messages.Count > 500)
{
Messages.RemoveAt(0);
}
// TODO: Auto-scroll if enabled
}
private static string ClassifyMessage(string line)
{
line = line.Trim();
// Try parsing as UmLogPacket (data packet)
var packet = UmLogPacket.TryParse(line);
if (packet is not null)
{
return packet.LogType.HasValue ? "UMLOG (Timed)" : "UMLOG (Button)";
}
// Try parsing as device info
var info = UmDeviceInfo.TryParse(line);
if (info is not null)
return "UMDEV";
if (line.Equals("OK", StringComparison.OrdinalIgnoreCase))
return "OK";
if (line.Equals("ERROR", StringComparison.OrdinalIgnoreCase))
return "ERROR";
if (line.StartsWith("$", StringComparison.Ordinal))
return "Unknown";
return "Text";
}
private static string? ParseMessage(string line, string type)
{
if (type.StartsWith("UMLOG"))
{
var packet = UmLogPacket.TryParse(line);
if (packet is not null)
{
var logTypeStr = packet.LogType.HasValue ? $"[{packet.LogType}] " : "";
return $"{logTypeStr}{packet.Mode}, {packet.Frequency}Hz, Signal={packet.Signal}, " +
$"Depth={packet.DepthRaw}, Current={packet.CurrentRaw}, Battery={packet.BatteryVoltage:F2}V";
}
}
else if (type == "UMDEV")
{
var info = UmDeviceInfo.TryParse(line);
if (info is not null)
{
return $"Mfr={info.Manufacturer}, Model={info.ModelName}, SN={info.SerialNumber}, FW={info.SoftwareVersion}";
}
}
return null;
}
private static string GetModeText(string mode)
{
return mode switch
{
"0" => "Single",
"1" => "Twin",
"2" => "Null",
"3" => "Sweep",
"4" => "Twin Sweep",
"5" => "Omni",
"6" => "Twin Omni",
_ => $"Mode {mode}"
};
}
private static string GetTypeColor(string type)
{
return type switch
{
"OK" => "Green",
"ERROR" => "Red",
"UMLOG (Button)" => "Blue",
"UMLOG (Timed)" => "DodgerBlue",
"UMDEV" => "Purple",
"Command" => "Orange",
_ when type.StartsWith("UMLOG") => "Blue",
_ => "Gray"
};
}
[RelayCommand]
private void ClearMessages()
{
Messages.Clear();
MessageCount = 0;
}
}

View File

@@ -88,7 +88,7 @@ public sealed partial class DeviceScanViewModel : ObservableObject
// Filter by device name prefix based on device type.
if (Kind == DeviceKind.Locator && !UmReceiverService.IsUmReceiverName(device.Name))
return;
if (Kind == DeviceKind.Maglink && !MaglinkService.IsMaglinkName(device.Name))
if (Kind == DeviceKind.RtkGps && !MaglinkService.IsMaglinkName(device.Name))
return;
MainThread.BeginInvokeOnMainThread(() =>

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:FieldLogger.ViewModels"
xmlns:models="clr-namespace:FieldLogger.Models"
x:Class="FieldLogger.Views.DebugPage"
x:DataType="vm:DebugViewModel"
Title="Debug">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="12">
<Label Text="Connection Status" FontAttributes="Bold" FontSize="16" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="10">
<Grid ColumnDefinitions="16,*,Auto" ColumnSpacing="8">
<Ellipse Grid.Column="0" WidthRequest="12" HeightRequest="12" VerticalOptions="Center"
Fill="{Binding ConnectionStateColor}" />
<Label Grid.Column="1" Text="{Binding ConnectionStatus}" VerticalOptions="Center" />
<Button Grid.Column="2" Text="Enable Push-Button" FontSize="12" Padding="10,4"
Command="{Binding EnablePushButtonLoggingCommand}"
IsEnabled="{Binding IsConnected}" />
</Grid>
</VerticalStackLayout>
</Border>
<Label Text="Timed Data Logging" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="10">
<Label Text="Enable continuous data streaming from UM Receiver" FontSize="12" TextColor="Gray" />
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
<Switch Grid.Column="0" IsToggled="{Binding IsTimedLoggingEnabled}" IsEnabled="{Binding IsConnected}" />
<Label Grid.Column="1" Text="{Binding TimedLoggingStatus}" VerticalOptions="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8" IsVisible="{Binding IsTimedLoggingEnabled}">
<Label Grid.Column="0" Text="{Binding LogPeriodLabel}" VerticalOptions="Center" FontSize="14" />
<Stepper Grid.Column="1" Minimum="10" Maximum="100" Increment="10" Value="{Binding LogPeriod}" />
</Grid>
</VerticalStackLayout>
</Border>
<Label Text="Message Log" FontAttributes="Bold" FontSize="16" Margin="0,12,0,0" />
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#F2F2F7, Dark=#1C1C1E}" StrokeShape="RoundRectangle 12" Padding="12">
<VerticalStackLayout Spacing="10">
<Grid ColumnDefinitions="*,Auto,Auto,Auto" ColumnSpacing="8">
<Label Grid.Column="0" Text="{Binding MessageCount, StringFormat='{0} messages'}" VerticalOptions="Center" FontSize="12" TextColor="Gray" />
<Label Grid.Column="1" Text="Show Hex" VerticalOptions="Center" FontSize="12" TextColor="Gray" />
<Switch Grid.Column="2" IsToggled="{Binding ShowHex}" />
<Button Grid.Column="3" Text="Clear" FontSize="12" Padding="10,4" Command="{Binding ClearMessagesCommand}" />
</Grid>
<BoxView HeightRequest="1" Color="{AppThemeBinding Light=#E0E0E0, Dark=#333}" />
<CollectionView ItemsSource="{Binding Messages}" HeightRequest="400">
<CollectionView.EmptyView>
<Label Text="No messages yet. Messages will appear here when received from the device."
TextColor="Gray" FontSize="12" HorizontalOptions="Center" VerticalOptions="Center" />
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:DebugMessage">
<Border StrokeThickness="0" Background="{AppThemeBinding Light=#FFFFFF, Dark=#2C2C2E}"
StrokeShape="RoundRectangle 8" Padding="8" Margin="0,2">
<VerticalStackLayout Spacing="4">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8">
<Label Grid.Column="0" Text="{Binding Timestamp, StringFormat='{0:HH:mm:ss.fff}'}"
FontSize="10" TextColor="Gray" FontFamily="Courier" />
<Label Grid.Column="1" Text="{Binding Type}"
FontSize="10" FontAttributes="Bold" TextColor="{Binding TypeColor}" />
<Label Grid.Column="2" Text="{Binding Direction}"
FontSize="10" TextColor="Gray" />
</Grid>
<Label Text="{Binding RawMessage}" FontSize="11" FontFamily="Courier" />
<Label Text="{Binding HexBytes}" FontSize="9" TextColor="DarkGray" FontFamily="Courier"
IsVisible="{Binding Source={RelativeSource AncestorType={x:Type vm:DebugViewModel}}, Path=ShowHex}" />
<Label Text="{Binding ParsedData}" FontSize="10" TextColor="Gray"
IsVisible="{Binding HasParsedData}" />
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,27 @@
using FieldLogger.ViewModels;
namespace FieldLogger.Views;
public partial class DebugPage : ContentPage
{
private readonly DebugViewModel _viewModel;
public DebugPage(DebugViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
}
protected override void OnAppearing()
{
base.OnAppearing();
_viewModel.OnAppearing();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
_viewModel.OnDisappearing();
}
}

View File

@@ -4,6 +4,8 @@ Cross-platform .NET MAUI app (Windows, macOS, iOS, Android) for logging utility
points from an **Underground Magnetics locating receiver** paired with RTK GPS positions
from a **Maglink (H11) RTK receiver**, both over BLE.
Maps API Key: AIzaSyDhH16gF-7UN-CBsTQGfQSHNGjLC6VJ5dI
## Solution layout
```