Files
HC_APTBS/ViewModels/Pages/DeveloperToolsDumpViewModel.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

367 lines
15 KiB
C#
Raw 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.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>Memory region selector for the dump card.</summary>
public enum DumpRegion
{
/// <summary>ROM range 0x00000x9FFF, read via KWP <c>ReadRomEeprom</c> (0x03).</summary>
Rom,
/// <summary>EEPROM range 0x000xFF, read via KWP <c>ReadEeprom</c> (0x19).
/// Note: only offsets 0x000xBF are valid per 256-byte block.</summary>
Eeprom,
}
/// <summary>
/// Developer Tools — ROM / EEPROM dump card. Iterates a user-supplied address
/// range in 13-byte chunks aligned to 256-byte block boundaries (mirroring the
/// legacy <c>DumpRom</c> / <c>DumpEeprom</c> routines in
/// <c>docs/dump functions.txt</c>) and writes the result to
/// <c>%UserProfile%\.HC_APTBS\dumps\{ident}_{swver1}_{start:X4}-{end:X4}.bin</c>.
///
/// <para>Compiled into Debug builds only — see <c>HC_APTBS.csproj</c>.</para>
/// </summary>
public sealed partial class DeveloperToolsDumpViewModel : ObservableObject
{
private const int MaxChunk = 13;
private const int BlockSize = 0x0100;
// EEPROM has only 0x000xBF readable per 256-byte block via the unauth path;
// the legacy DumpEeprom routine treats this as the "valid bytes per block" cap.
private const int EepromValidBytesPerBlock = 0x00C0;
private readonly DeveloperPageViewModel _parent;
private readonly IKwpService _kwp;
private readonly IAppLogger _log;
private const string LogId = nameof(DeveloperToolsDumpViewModel);
/// <summary>
/// Threshold above which an in-flight dump shows the prominent overlay banner
/// (instead of just the inline progress bar). 0x0A00 = 2560 bytes; below that,
/// reads complete in a few seconds and don't need the bigger indicator.
/// </summary>
public const int LargeDumpByteThreshold = 0x0A00;
public DeveloperToolsDumpViewModel(DeveloperPageViewModel parent, IKwpService kwp, IAppLogger log)
{
_parent = parent;
_kwp = kwp;
_log = log;
// Sensible default: full ROM dump.
StartAddressHex = "0000";
EndAddressHex = "9FFF";
}
// ── Inputs ────────────────────────────────────────────────────────────────
/// <summary>Region radio: ROM or EEPROM. Updates address-range bounds + defaults.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DumpCommand))]
private DumpRegion _region = DumpRegion.Rom;
/// <summary>Start address in hex (no <c>0x</c> prefix required).</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DumpCommand))]
private string _startAddressHex = string.Empty;
/// <summary>End address in hex (inclusive).</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DumpCommand))]
private string _endAddressHex = string.Empty;
/// <summary>Per-byte progress, 0..1, for the progress bar.</summary>
[ObservableProperty] private double _progress;
/// <summary>True while a dump is in flight — disables Dump button.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(DumpCommand))]
private bool _isDumping;
/// <summary>
/// True while a dump is in flight AND the requested range is larger than
/// <see cref="LargeDumpByteThreshold"/>. Drives the page-level banner overlay
/// so the operator sees clearly that a long dump is running.
/// </summary>
[ObservableProperty] private bool _isLargeDumpInProgress;
/// <summary>Total bytes the current dump intends to read.</summary>
[ObservableProperty] private int _totalBytes;
/// <summary>Bytes received so far for the current dump.</summary>
[ObservableProperty] private int _bytesCollected;
/// <summary>Address of the next byte to read (for the banner display).</summary>
[ObservableProperty] private int _currentAddress;
/// <summary>Status / hint text shown under the inputs.</summary>
[ObservableProperty] private string _statusText = string.Empty;
/// <summary>True when the ROM region is selected. Two-way bindable for radio buttons.</summary>
public bool IsRomSelected
{
get => Region == DumpRegion.Rom;
set { if (value) Region = DumpRegion.Rom; }
}
/// <summary>True when the EEPROM region is selected. Two-way bindable for radio buttons.</summary>
public bool IsEepromSelected
{
get => Region == DumpRegion.Eeprom;
set { if (value) Region = DumpRegion.Eeprom; }
}
partial void OnRegionChanged(DumpRegion value)
{
// Reset to typical full-range defaults so the user doesn't accidentally
// dump a ROM-sized range out of EEPROM and get NAKs all the way.
switch (value)
{
case DumpRegion.Rom:
StartAddressHex = "0000";
EndAddressHex = "9FFF";
break;
case DumpRegion.Eeprom:
StartAddressHex = "0000";
EndAddressHex = "00BF";
break;
}
OnPropertyChanged(nameof(IsRomSelected));
OnPropertyChanged(nameof(IsEepromSelected));
}
// ── Command ───────────────────────────────────────────────────────────────
/// <summary>
/// Iterates the selected range in 13-byte chunks aligned to 256-byte blocks
/// and writes the bytes to disk. Filename built from the current pump
/// identifier and SW ver 1 read by the K-Line session.
/// </summary>
[RelayCommand(CanExecute = nameof(CanDump))]
private async Task DumpAsync()
{
if (!TryParseAddresses(out int startAddr, out int endAddr, out string parseError))
{
StatusText = parseError;
return;
}
var ident = (_parent.Root.PumpIdentification.KlinePumpId ?? string.Empty).Trim();
var swVer1 = (_parent.Root.PumpIdentification.KlineSwVersion1 ?? string.Empty).Trim();
if (ident.Length == 0 || swVer1.Length == 0)
{
StatusText = "Read pump identification first — ident and SW ver 1 are required for the filename.";
return;
}
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".HC_APTBS", "dumps");
try { Directory.CreateDirectory(dir); }
catch (Exception ex)
{
StatusText = $"Could not create dump folder: {ex.Message}";
return;
}
var fileName = $"{Sanitize(ident)}_{Sanitize(swVer1)}_{startAddr:X4}-{endAddr:X4}.bin";
var fullPath = Path.Combine(dir, fileName);
int totalBytes = endAddr - startAddr + 1;
TotalBytes = totalBytes;
BytesCollected = 0;
CurrentAddress = startAddr;
IsDumping = true;
IsLargeDumpInProgress = totalBytes > LargeDumpByteThreshold;
Progress = 0;
var buffer = new List<byte>(totalBytes);
int collected = 0;
try
{
_parent.AppendLog(DeveloperLogDirection.Info,
$"DUMP {Region} 0x{startAddr:X4}-0x{endAddr:X4} ({totalBytes} bytes) → {fileName}");
int addr = startAddr;
while (addr <= endAddr)
{
CurrentAddress = addr;
int blockBase = addr & 0xFF00;
int offsetInBlock = addr & 0x00FF;
int blockEndAbs;
if (Region == DumpRegion.Eeprom)
{
if (offsetInBlock >= EepromValidBytesPerBlock)
{
// Skip the unreadable tail of this block.
addr = blockBase + BlockSize;
continue;
}
blockEndAbs = blockBase + EepromValidBytesPerBlock - 1;
}
else
{
blockEndAbs = blockBase + BlockSize - 1;
}
int maxReadableAbs = Math.Min(endAddr, blockEndAbs);
while (addr <= maxReadableAbs)
{
int remaining = maxReadableAbs - addr + 1;
byte len = (byte)Math.Min(MaxChunk, remaining);
IReadOnlyList<byte> chunk = Region == DumpRegion.Rom
? await _kwp.ReadRomEepromAsync((ushort)addr, len)
: await _kwp.ReadEepromAsync((ushort)addr, len);
if (chunk.Count == 0)
{
StatusText = $"Read failed at 0x{addr:X4} (NAK or no session). Saving partial dump.";
_parent.AppendLog(DeveloperLogDirection.Info,
$"DUMP aborted at 0x{addr:X4} — {collected}/{totalBytes} bytes captured.");
break;
}
buffer.AddRange(chunk);
addr += chunk.Count;
collected += chunk.Count;
BytesCollected = collected;
CurrentAddress = addr;
Progress = (double)collected / totalBytes;
}
if (addr <= endAddr && (addr & 0x00FF) == 0)
{
// Already advanced to the next block — keep going.
continue;
}
if (addr <= endAddr)
{
// We broke out of the inner loop early due to a read failure.
break;
}
}
if (buffer.Count > 0)
{
File.WriteAllBytes(fullPath, buffer.ToArray());
_parent.AppendLog(DeveloperLogDirection.Info,
$"DUMP saved {buffer.Count} byte(s) → {fullPath}");
StatusText = buffer.Count == totalBytes
? $"Dump complete: {buffer.Count} bytes saved."
: $"Partial dump: {buffer.Count}/{totalBytes} bytes saved.";
}
else
{
StatusText = "Dump produced no bytes — nothing was written.";
_parent.AppendLog(DeveloperLogDirection.Info, "DUMP produced no bytes — skipped file write.");
}
}
catch (Exception ex)
{
_log.Error(LogId, $"DumpAsync failed: {ex.Message}");
StatusText = $"Dump failed: {ex.Message}";
_parent.AppendLog(DeveloperLogDirection.Info, $"DUMP error: {ex.Message}");
}
finally
{
IsDumping = false;
IsLargeDumpInProgress = false;
}
// Modal confirmation so the operator can't miss completion of a long dump.
var savedToText = buffer.Count > 0 ? $"\n\nFile: {fullPath}" : string.Empty;
var summary = $"{StatusText}{savedToText}";
var image = buffer.Count == totalBytes
? System.Windows.MessageBoxImage.Information
: System.Windows.MessageBoxImage.Warning;
System.Windows.Application.Current?.Dispatcher.BeginInvoke(() =>
System.Windows.MessageBox.Show(
summary,
"Dump finished",
System.Windows.MessageBoxButton.OK,
image));
}
private bool CanDump()
{
if (IsDumping) return false;
if (string.IsNullOrWhiteSpace(StartAddressHex) || string.IsNullOrWhiteSpace(EndAddressHex)) return false;
return TryParseAddresses(out _, out _, out _);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private bool TryParseAddresses(out int startAddr, out int endAddr, out string error)
{
startAddr = 0;
endAddr = 0;
error = string.Empty;
if (!TryParseHexAddress(StartAddressHex, out startAddr))
{
error = $"Bad start address '{StartAddressHex}'.";
return false;
}
if (!TryParseHexAddress(EndAddressHex, out endAddr))
{
error = $"Bad end address '{EndAddressHex}'.";
return false;
}
if (startAddr > endAddr)
{
error = "Invalid range: start > end.";
return false;
}
int min, max;
switch (Region)
{
case DumpRegion.Rom:
min = 0x0000;
max = 0x9FFF;
break;
case DumpRegion.Eeprom:
min = 0x0000;
max = 0x00FF;
break;
default:
error = "Unknown region.";
return false;
}
if (startAddr < min || endAddr > max)
{
error = $"Range outside {Region} bounds (0x{min:X4}0x{max:X4}).";
return false;
}
return true;
}
private static bool TryParseHexAddress(string text, out int value)
{
value = 0;
if (string.IsNullOrWhiteSpace(text)) return false;
var t = text.Trim();
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
return int.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
}
private static string Sanitize(string s)
{
var chars = s.Select(c => char.IsLetterOrDigit(c) || c == '.' || c == '_' || c == '-' ? c : '_').ToArray();
return new string(chars);
}
}
}