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:
2026-05-07 13:59:50 +02:00
parent da0581967b
commit 827b811b39
102 changed files with 7522 additions and 1798 deletions

View 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 12 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 12 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();
}
}
}
}