feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
Bundles several feature streams that have been iterating on the working tree: - Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the identification card, manual KWP write + transaction log, ROM/EEPROM dump card with progress banner and completion message, persisted custom-commands library, persisted EEPROM passwords library. New service primitives: IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync. Persistence mirrors the Clients XML pattern in two new files (custom_commands.xml, eeprom_passwords.xml). - Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and progress dialog VM, gated on dashboard alarms. - BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at 0x0106 via IKwpService.ReadBipStatusAsync; status definitions in BipStatusDefinition. - Tests page redesign: TestSectionCard + PhaseTileView replacing the old TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/ TestSectionView controls and their VMs. - Pump command sliders: Fluent thick-track style with overhang thumb, click-anywhere-and-drag, mouse-wheel adjustment. - Window startup: app.manifest declares PerMonitorV2 DPI awareness, MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and maximizes there (after the hook is in place) so the app fits the work area exactly on any display configuration. - Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and dump-functions reference docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
268
ViewModels/Pages/DeveloperPageViewModel.cs
Normal file
268
ViewModels/Pages/DeveloperPageViewModel.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using HC_APTBS.Models;
|
||||
using HC_APTBS.Services;
|
||||
|
||||
namespace HC_APTBS.ViewModels.Pages
|
||||
{
|
||||
/// <summary>
|
||||
/// ViewModel for the Developer Tools navigation page — exposes a raw KWP/K-Line
|
||||
/// custom-command console for hardware development and debugging.
|
||||
///
|
||||
/// <para>
|
||||
/// Compiled into <b>Debug builds only</b>. The project's
|
||||
/// <c>DEVELOPER_TOOLS</c> compile-time symbol gates every reference to this
|
||||
/// type, and the page files are <c>Compile Remove</c>'d from Release builds
|
||||
/// in <c>HC_APTBS.csproj</c>, so this page does not appear at all in
|
||||
/// consumer builds.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class DeveloperPageViewModel : ObservableObject
|
||||
{
|
||||
private const string LogId = nameof(DeveloperPageViewModel);
|
||||
|
||||
private readonly IKwpService _kwp;
|
||||
private readonly IAppLogger _log;
|
||||
|
||||
/// <summary>Root coordinator — exposes K-Line state for status binding.</summary>
|
||||
public MainViewModel Root { get; }
|
||||
|
||||
// ── Input / output state ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Hex bytes typed by the developer. Whitespace, commas and dashes are
|
||||
/// accepted as separators; e.g. <c>"18 00 03 FF FF"</c> or <c>"18-00-03-FF-FF"</c>.
|
||||
/// </summary>
|
||||
[ObservableProperty] private string _hexInput = string.Empty;
|
||||
|
||||
/// <summary>Single-line status message (parse error, send result, …).</summary>
|
||||
[ObservableProperty] private string _statusText = string.Empty;
|
||||
|
||||
/// <summary>True while a send is in flight — disables the Send button.</summary>
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
/// <summary>True when the underlying K-Line session is open.</summary>
|
||||
[ObservableProperty] private bool _isSessionOpen;
|
||||
|
||||
/// <summary>Time-stamped record of every TX/RX packet exchanged on this page.</summary>
|
||||
public ObservableCollection<DeveloperLogEntry> Log { get; } = new();
|
||||
|
||||
// ── Child VMs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Pump identification card VM, reused from the singleton on <see cref="MainViewModel"/>.</summary>
|
||||
public ViewModels.PumpIdentificationViewModel Identification => Root.PumpIdentification;
|
||||
|
||||
/// <summary>ROM / EEPROM dump card.</summary>
|
||||
public DeveloperToolsDumpViewModel Dump { get; }
|
||||
|
||||
/// <summary>Saved KWP custom commands library.</summary>
|
||||
public DeveloperToolsCommandsViewModel Commands { get; }
|
||||
|
||||
/// <summary>EEPROM unlock password library.</summary>
|
||||
public DeveloperToolsPasswordsViewModel Passwords { get; }
|
||||
|
||||
public DeveloperPageViewModel(
|
||||
MainViewModel root,
|
||||
IKwpService kwp,
|
||||
IConfigurationService config,
|
||||
IAppLogger log)
|
||||
{
|
||||
Root = root;
|
||||
_kwp = kwp;
|
||||
_log = log;
|
||||
|
||||
IsSessionOpen = root.KLineState == KLineConnectionState.Connected;
|
||||
root.PropertyChanged += OnRootPropertyChanged;
|
||||
|
||||
Dump = new DeveloperToolsDumpViewModel(this, kwp, log);
|
||||
Commands = new DeveloperToolsCommandsViewModel(this, kwp, config, log);
|
||||
Passwords = new DeveloperToolsPasswordsViewModel(this, kwp, config, log);
|
||||
}
|
||||
|
||||
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MainViewModel.KLineState))
|
||||
{
|
||||
IsSessionOpen = Root.KLineState == KLineConnectionState.Connected;
|
||||
SendCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Sends the hex payload as a raw KWP custom packet over the persistent session.</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanSend))]
|
||||
private async Task SendAsync()
|
||||
{
|
||||
if (!TryParseHex(HexInput, out var bytes, out var error))
|
||||
{
|
||||
StatusText = $"Parse error: {error}";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var hex = FormatHex(bytes);
|
||||
StatusText = $"Sending {bytes.Length} byte(s)…";
|
||||
AppendLog(DeveloperLogDirection.Tx, hex);
|
||||
_log.Info(LogId, $"TX {hex}");
|
||||
|
||||
var responses = await _kwp.SendRawCustomAsync(bytes, CancellationToken.None);
|
||||
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
AppendLog(DeveloperLogDirection.Info, "(no response)");
|
||||
StatusText = "No response packets.";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pkt in responses)
|
||||
{
|
||||
var rxHex = FormatHex(pkt);
|
||||
AppendLog(DeveloperLogDirection.Rx, rxHex);
|
||||
_log.Info(LogId, $"RX {rxHex}");
|
||||
}
|
||||
StatusText = $"Received {responses.Count} packet(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusText = $"Send failed: {ex.Message}";
|
||||
AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}");
|
||||
_log.Warning(LogId, $"SendAsync failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanSend() => !IsBusy && IsSessionOpen;
|
||||
|
||||
/// <summary>Clears the on-screen log (does not affect AppLogger output).</summary>
|
||||
[RelayCommand]
|
||||
private void ClearLog()
|
||||
{
|
||||
Log.Clear();
|
||||
StatusText = string.Empty;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Appends a row to the shared transaction log. Exposed as <c>internal</c> so
|
||||
/// the page's child VMs (dump, command library, password library) can stream
|
||||
/// their own TX/RX rows into the same scrolling log.
|
||||
/// </summary>
|
||||
internal void AppendLog(DeveloperLogDirection dir, string text)
|
||||
{
|
||||
// Keep memory bounded — drop oldest entries when the list grows large.
|
||||
const int max = 500;
|
||||
if (Log.Count >= max) Log.RemoveAt(0);
|
||||
Log.Add(new DeveloperLogEntry(DateTime.Now, dir, text));
|
||||
}
|
||||
|
||||
/// <summary>Internal accessor so child VMs can push status messages.</summary>
|
||||
internal void SetStatus(string text) => StatusText = text;
|
||||
|
||||
/// <summary>
|
||||
/// Formats a byte array as a space-separated uppercase hex string. Internal so
|
||||
/// child VMs can produce log rows that match the parent's formatting.
|
||||
/// </summary>
|
||||
internal static string FormatHex(byte[] bytes) =>
|
||||
string.Join(" ", bytes.Select(b => b.ToString("X2")));
|
||||
|
||||
/// <summary>
|
||||
/// Same formatter for any read-only sequence (e.g. <see cref="System.Collections.Generic.IReadOnlyList{T}"/>
|
||||
/// returned by KWP read primitives).
|
||||
/// </summary>
|
||||
internal static string FormatHex(System.Collections.Generic.IReadOnlyList<byte> bytes)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(bytes.Count * 3);
|
||||
for (int i = 0; i < bytes.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(' ');
|
||||
sb.Append(bytes[i].ToString("X2"));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a hex byte sequence. Accepts spaces, commas, dashes, and any
|
||||
/// combination as separators. Each token must be 1–2 hex digits.
|
||||
/// </summary>
|
||||
internal static bool TryParseHex(string input, out byte[] bytes, out string error)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
error = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
error = "input is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
var separators = new[] { ' ', ',', '-', '\t', '\r', '\n' };
|
||||
var tokens = input.Split(separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length == 0)
|
||||
{
|
||||
error = "no hex bytes found";
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new byte[tokens.Length];
|
||||
for (int i = 0; i < tokens.Length; i++)
|
||||
{
|
||||
var t = tokens[i];
|
||||
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
|
||||
if (t.Length is < 1 or > 2)
|
||||
{
|
||||
error = $"token #{i + 1} '{tokens[i]}' must be 1–2 hex digits";
|
||||
return false;
|
||||
}
|
||||
if (!byte.TryParse(t, System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out byte b))
|
||||
{
|
||||
error = $"token #{i + 1} '{tokens[i]}' is not valid hex";
|
||||
return false;
|
||||
}
|
||||
result[i] = b;
|
||||
}
|
||||
|
||||
bytes = result;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Log entry direction — TX (sent), RX (received), or Info (status).</summary>
|
||||
public enum DeveloperLogDirection { Tx, Rx, Info }
|
||||
|
||||
/// <summary>One row in the Developer Tools transaction log.</summary>
|
||||
public sealed record DeveloperLogEntry(DateTime Timestamp, DeveloperLogDirection Direction, string Hex)
|
||||
{
|
||||
/// <summary>Pre-formatted line text for binding into a single TextBlock.</summary>
|
||||
public string Display
|
||||
{
|
||||
get
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('[').Append(Timestamp.ToString("HH:mm:ss.fff")).Append(']').Append(' ');
|
||||
sb.Append(Direction switch
|
||||
{
|
||||
DeveloperLogDirection.Tx => "TX",
|
||||
DeveloperLogDirection.Rx => "RX",
|
||||
_ => " "
|
||||
});
|
||||
sb.Append(' ').Append(Hex);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user