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>
367 lines
15 KiB
C#
367 lines
15 KiB
C#
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 0x0000–0x9FFF, read via KWP <c>ReadRomEeprom</c> (0x03).</summary>
|
||
Rom,
|
||
/// <summary>EEPROM range 0x00–0xFF, read via KWP <c>ReadEeprom</c> (0x19).
|
||
/// Note: only offsets 0x00–0xBF 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 0x00–0xBF 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);
|
||
}
|
||
}
|
||
}
|