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

@@ -30,7 +30,11 @@ namespace HC_APTBS.ViewModels
/// <summary>Application configuration: safety limits, PID, motor, report, K-Line, language.</summary>
Settings = 4,
/// <summary>Session-only history of completed test runs with detail view and PDF export.</summary>
Results = 5
Results = 5,
#if DEVELOPER_TOOLS
/// <summary>Developer Tools page: raw K-Line / KWP custom command console. Debug builds only.</summary>
Developer = 6
#endif
}
/// <summary>
@@ -174,7 +178,7 @@ namespace HC_APTBS.ViewModels
/// ViewModel for the BIP-STATUS display (PSG5-PI pumps only).
/// <see cref="BipDisplayViewModel.HasDefinition"/> is false for non-PSG5-PI pumps.
/// </summary>
public BipDisplayViewModel BipDisplay { get; } = new();
public BipDisplayViewModel BipDisplay { get; }
/// <summary>ViewModel for the Dashboard's active-alarm list.</summary>
public DashboardAlarmsViewModel DashboardAlarms { get; }
@@ -202,6 +206,11 @@ namespace HC_APTBS.ViewModels
/// <summary>Results navigation page VM (session-only test-run history).</summary>
public ResultsPageViewModel ResultsPage { get; private set; } = null!;
#if DEVELOPER_TOOLS
/// <summary>Developer Tools page VM. Debug builds only — excluded from consumer Release builds.</summary>
public Pages.DeveloperPageViewModel DeveloperPage { get; private set; } = null!;
#endif
// ── Navigation state ──────────────────────────────────────────────────────
/// <summary>Currently selected top-level navigation page.</summary>
@@ -245,6 +254,7 @@ namespace HC_APTBS.ViewModels
AngleDisplay = new AngleDisplayViewModel(configService);
DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms);
DtcList = new DtcListViewModel(kwpService, localizationService, logger);
BipDisplay = new BipDisplayViewModel(localizationService);
// Page ViewModels are thin façades over the child VMs above; they hold a
// reference back to this coordinator so page XAML can bind MainViewModel-owned
// values via {Binding Root.X}.
@@ -252,9 +262,12 @@ namespace HC_APTBS.ViewModels
BenchPage = new BenchPageViewModel(this, benchService, configService);
PumpPage = new PumpPageViewModel(this, DtcList);
TestsPage = new TestsPageViewModel(this, configService, localizationService);
SettingsPage = new SettingsPageViewModel(configService, localizationService);
SettingsPage = new SettingsPageViewModel(configService, localizationService, logger);
SettingsPage.SettingsSaved += OnSettingsSaved;
ResultsPage = new ResultsPageViewModel(this, pdfService, configService, localizationService, logger);
#if DEVELOPER_TOOLS
DeveloperPage = new Pages.DeveloperPageViewModel(this, kwpService, configService, logger);
#endif
// React to pump changes from the identification child VM.
PumpIdentification.PumpChanged += OnPumpChanged;
@@ -373,6 +386,12 @@ namespace HC_APTBS.ViewModels
_unlock.UnlockCompleted += success => App.Current.Dispatcher.Invoke(
() => _lastUnlockSucceeded = success);
// Re-trigger unlock on any UNLOCKED → LOCKED transition (pump swap, power glitch, etc.)
_unlock.PumpRelocked += OnPumpRelocked;
// Safety-net: if a K-Line read completes and the pump is still LOCKED, re-run unlock.
PumpIdentification.KlineReadCompleted += OnKlineReadCompleted;
// KWP pump power-cycle callbacks
kwpService.PumpDisconnectRequested += OnKwpDisconnectPump;
kwpService.PumpReconnectRequested += OnKwpReconnectPump;
@@ -424,6 +443,7 @@ namespace HC_APTBS.ViewModels
PumpControl.IsPreInAvailable = pump.HasPreInjection;
PumpControl.IsEnabled = true;
PumpControl.Reset();
DfiViewModel.Reset();
_log.Info(LogId, $"OnPumpChanged: slider gate opened for {pump.Id}");
// Cancel any in-flight "wait for CAN liveness then unlock" gate from
@@ -613,12 +633,74 @@ namespace HC_APTBS.ViewModels
_unlockCts = new CancellationTokenSource();
CurrentUnlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
CurrentUnlockVm.RequestClose += CloseUnlockDialog;
CurrentUnlockVm.RequestRetry += () => RestartUnlockForSameSelection(pump);
// Start unlock in background — ViewModel tracks via event subscriptions.
_unlockTask = _unlock.UnlockAsync(pump, _unlockCts.Token);
_ = _unlockTask.ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted);
}
/// <summary>
/// Handles the UNLOCKED → LOCKED transition raised by the unlock observer on the CAN
/// read thread. Re-runs the unlock flow against the current pump without touching CAN
/// parameter registrations, the test panel, or bench senders (the pump model is unchanged).
/// </summary>
private void OnPumpRelocked()
{
App.Current.Dispatcher.BeginInvoke(new Action(() =>
{
var pump = _previousPump;
if (pump == null || pump.UnlockType == 0) return;
// Skip if an unlock is already in-flight — the LOCKED frames that arrive
// during Phase 1 of an ongoing unlock would otherwise cause infinite restarts.
if (_unlockTask != null && !_unlockTask.IsCompleted) return;
_log.Warning(LogId, $"Pump {pump.Id} transitioned UNLOCKED → LOCKED — re-triggering unlock");
RestartUnlockForSameSelection(pump);
}));
}
/// <summary>
/// Handles K-Line read completion. If the pump requires unlock and the observer reports
/// LOCKED, re-runs the unlock flow. This is a safety net for the first-contact window
/// where the CAN observer may not yet have received a frame from the new pump.
/// </summary>
private void OnKlineReadCompleted(string pumpId, string serial)
{
var pump = _previousPump;
if (pump == null || !string.Equals(pump.Id, pumpId, StringComparison.OrdinalIgnoreCase)) return;
if (pump.UnlockType == 0) return;
if (_unlock.IsPumpUnlocked) return;
// Skip if an unlock is already running.
if (_unlockTask != null && !_unlockTask.IsCompleted) return;
_log.Info(LogId, $"K-Line read completed on {pumpId}; observer reports LOCKED — re-triggering unlock");
RestartUnlockForSameSelection(pump);
}
/// <summary>
/// Tears down the active unlock state and re-runs the liveness-wait → unlock pipeline
/// against the already-selected pump. Used when the pump re-locks without a model change
/// (physical swap of a same-ID unit, power instability, etc.).
/// </summary>
private void RestartUnlockForSameSelection(PumpDefinition pump)
{
_pumpLivenessCts?.Cancel();
_pumpLivenessCts?.Dispose();
_pumpLivenessCts = null;
_unlockCts?.Cancel();
_unlock.StopSenders();
_unlock.StopObserver();
_lastUnlockSucceeded = false;
_pumpLivenessCts = new CancellationTokenSource();
_ = WaitForPumpCanThenUnlockAsync(pump, _pumpLivenessCts.Token);
}
/// <summary>
/// Dismisses the unlock snackbar and disposes its ViewModel. Does NOT stop
/// the persistent CAN senders — those continue running until the next pump