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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
24
FieldLogger/Models/DebugMessage.cs
Normal file
24
FieldLogger/Models/DebugMessage.cs
Normal 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("-", " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
254
FieldLogger/ViewModels/DebugViewModel.cs
Normal file
254
FieldLogger/ViewModels/DebugViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(() =>
|
||||
|
||||
91
FieldLogger/Views/DebugPage.xaml
Normal file
91
FieldLogger/Views/DebugPage.xaml
Normal 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>
|
||||
27
FieldLogger/Views/DebugPage.xaml.cs
Normal file
27
FieldLogger/Views/DebugPage.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user