Files
HC_APTBS/ViewModels/Pages/DeveloperPageViewModel.cs
LucianoDev 827b811b39 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>
2026-05-07 13:59:50 +02:00

269 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}
}