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>
269 lines
11 KiB
C#
269 lines
11 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|
||
}
|