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

@@ -119,6 +119,35 @@ namespace HC_APTBS.ViewModels
}
_bench.SetRelay(RelayNames.OilPump, value);
// The dialog's ShowDialog call runs a nested dispatcher message pump.
// While it was blocking, RefreshFromTick may have fired and written
// _isOilPumpOn back to the stale relay.State value (false, because
// SetRelay above hadn't run yet). Re-assert the backing field now
// that relay.State is committed so IsOilPumpOn reports the correct
// value to callers downstream (e.g. TestsPageViewModel.StartTestAsync
// which guards on it right after this setter returns).
// See docs/gotcha-oil-pump-dialog-race.md.
if (_isOilPumpOn != value)
{
_isOilPumpOn = value;
OnPropertyChanged(nameof(IsOilPumpOn));
}
}
/// <summary>
/// Energises the oil-pump relay and flags <see cref="IsOilPumpOn"/> without
/// presenting the leak-check confirmation dialog. Used by the Dashboard
/// "Connect &amp; Auto Test" flow when the operator has opted in via
/// <see cref="AppSettings.AutoTestSkipsOilPumpConfirm"/>. Writes the backing
/// field directly to avoid re-entering <see cref="OnIsOilPumpOnChanged"/>.
/// </summary>
public void TurnOilPumpOnSilent()
{
if (_isOilPumpOn) return;
_bench.SetRelay(RelayNames.OilPump, true);
_isOilPumpOn = true;
OnPropertyChanged(nameof(IsOilPumpOn));
}
// ── RPM commands ──────────────────────────────────────────────────────────
@@ -230,12 +259,23 @@ namespace HC_APTBS.ViewModels
// ── Refresh (called from MainViewModel timer tick) ────────────────────────
/// <summary>
/// Updates live counter readback from CAN.
/// Updates live counter readback from CAN, and mirrors the oil-pump relay
/// state so this VM's <see cref="IsOilPumpOn"/> stays in sync even when the
/// relay is toggled outside the manual Bench page (e.g. the Dashboard
/// auto-test orchestrator). Writes through the backing field to avoid
/// re-triggering the confirmation dialog in <see cref="OnIsOilPumpOnChanged"/>.
/// Called on the UI thread from <see cref="MainViewModel.OnRefreshTick"/>.
/// </summary>
public void RefreshFromTick()
{
BenchCounterValue = _bench.ReadBenchParameter(BenchParameterNames.BenchCounter);
bool relayOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
if (_isOilPumpOn != relayOn)
{
_isOilPumpOn = relayOn;
OnPropertyChanged(nameof(IsOilPumpOn));
}
}
}
}

View File

@@ -0,0 +1,166 @@
using System.Collections.ObjectModel;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// Represents one row in the BIP-STATUS display.
/// </summary>
public sealed partial class BipRowViewModel : ObservableObject
{
/// <summary>Zero-based index of this BIP definition entry.</summary>
public int Index { get; init; }
/// <summary>16-bit nibble pattern from the CFG (displayed as "0xXXXX").</summary>
public string HexPattern { get; init; } = string.Empty;
/// <summary>Raw hex value — used to re-resolve the description on language change.</summary>
public ushort RawHex { get; init; }
/// <summary>SpecialFunction from the CFG — part of the localization key.</summary>
public int SpecialFunction { get; init; }
/// <summary>Original XML text, used as fallback when no resource key matches.</summary>
public string FallbackDescription { get; init; } = string.Empty;
/// <summary>Human-readable description shown on match; updated on language switch.</summary>
[ObservableProperty] private string _description = string.Empty;
/// <summary>HTML hex colour for the status indicator: green = inactive, red = match detected.</summary>
[ObservableProperty] private string _color = "#26C200";
/// <summary>True when this BIP pattern currently matches the captured word.</summary>
[ObservableProperty] private bool _isActive;
}
/// <summary>
/// ViewModel for the BIP-STATUS display user control.
///
/// <para>
/// Only populated for PSG5-PI pumps (those whose <see cref="PumpDefinition.BipStatus"/>
/// is non-null). When <see cref="HasDefinition"/> is false the view hides itself.
/// </para>
/// </summary>
public sealed partial class BipDisplayViewModel : ObservableObject
{
private readonly ILocalizationService _loc;
// ── Properties ────────────────────────────────────────────────────────────
/// <summary>True when the current pump has BIP definitions; controls view visibility.</summary>
[ObservableProperty] private bool _hasDefinition;
/// <summary>Last raw BIP word received from the ECU (displayed as hex for diagnostics).</summary>
[ObservableProperty] private string _rawValue = "";
/// <summary>Ordered rows for the BIP definition table.</summary>
public ObservableCollection<BipRowViewModel> Rows { get; } = new();
// ── Construction ──────────────────────────────────────────────────────────
/// <summary>
/// Initializes the view model and subscribes to language-change notifications
/// so that row descriptions update automatically when the operator switches language.
/// </summary>
public BipDisplayViewModel(ILocalizationService loc)
{
_loc = loc;
_loc.LanguageChanged += RefreshDescriptions;
}
// ── Public API ────────────────────────────────────────────────────────────
/// <summary>
/// Loads the BIP definition for the selected pump and resets the display.
/// Pass <see langword="null"/> to hide the control (non-PSG5-PI pump selected).
/// Must be called on the UI thread.
/// </summary>
public void LoadDefinition(PumpBipDefinition? bipDef)
{
Rows.Clear();
RawValue = "";
if (bipDef == null || bipDef.Bits.Count == 0)
{
HasDefinition = false;
return;
}
foreach (var d in bipDef.Bits)
{
Rows.Add(new BipRowViewModel
{
Index = d.Index,
HexPattern = $"0x{d.HexPattern:X4}",
RawHex = d.HexPattern,
SpecialFunction = d.SpecialFunction,
FallbackDescription = d.Description,
Description = ResolveDescription(d.HexPattern, d.SpecialFunction, d.Description),
Color = "#26C200",
IsActive = false
});
}
HasDefinition = true;
}
/// <summary>
/// Updates the display with a newly captured BIP status word.
/// Marks matching (and enabled) rows as active.
/// Must be called on the UI thread.
/// </summary>
/// <param name="bipDef">Current pump's BIP definition.</param>
/// <param name="rawWord">Raw 16-bit value read from ECU RAM 0x0106.</param>
public void UpdateBipWord(PumpBipDefinition bipDef, ushort rawWord)
{
RawValue = $"0x{rawWord:X4}";
for (int i = 0; i < Rows.Count && i < bipDef.Bits.Count; i++)
{
var def = bipDef.Bits[i];
var row = Rows[i];
// Bitmask match: all pattern bits must be set in rawWord.
bool matches = def.Enabled && (rawWord & def.HexPattern) == def.HexPattern;
row.IsActive = matches;
row.Color = matches ? "#FF1E1E" : "#26C200";
}
}
/// <summary>Resets all rows to inactive / green without clearing the definitions.</summary>
public void Reset()
{
foreach (var row in Rows)
{
row.IsActive = false;
row.Color = "#26C200";
}
RawValue = "";
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Returns the localized description for a BIP entry keyed by (hex, specialFunction),
/// falling back to the raw XML text when no resource key is found.
/// </summary>
private static string ResolveDescription(ushort hex, int sf, string fallback)
{
var key = $"Pump.Bip.Desc.{hex:X4}.{sf}";
return Application.Current.Resources[key]?.ToString() ?? fallback;
}
/// <summary>
/// Re-resolves all row descriptions against the now-active resource dictionary.
/// Called by <see cref="ILocalizationService.LanguageChanged"/>.
/// </summary>
private void RefreshDescriptions()
{
foreach (var row in Rows)
row.Description = ResolveDescription(row.RawHex, row.SpecialFunction, row.FallbackDescription);
}
}
}

View File

@@ -99,6 +99,16 @@ namespace HC_APTBS.ViewModels
});
}
/// <summary>
/// Clears the DFI display and slider so the previous pump's value is not
/// shown stale until a new K-Line read populates it. Called on pump change.
/// </summary>
public void Reset()
{
CurrentDfi = 0;
SliderRaw = 0;
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Reads the current DFI value from the ECU over K-Line.</summary>

View File

@@ -0,0 +1,159 @@
using System;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the Dashboard "Connect &amp; Auto Test" snackbar.
/// Receives <see cref="AutoTestState"/> transitions and failure reasons from the
/// orchestrator, exposes snackbar-friendly bindings (progress, phase text, success),
/// and forwards the Cancel click to the orchestrator's CTS.
/// Modelled on <see cref="UnlockProgressViewModel"/>.
/// </summary>
public sealed partial class AutoTestProgressViewModel : ObservableObject, IDisposable
{
private readonly IAutoTestOrchestrator _orchestrator;
private readonly ILocalizationService _loc;
/// <summary>Creates the ViewModel and subscribes to orchestrator events.</summary>
public AutoTestProgressViewModel(IAutoTestOrchestrator orchestrator, ILocalizationService loc)
{
_orchestrator = orchestrator;
_loc = loc;
_typeLabel = _loc.GetString("AutoTest.TypeLabel");
_phaseText = _loc.GetString("AutoTest.State.Preflight");
_isCancellable = true;
_orchestrator.StateChanged += OnStateChanged;
_orchestrator.Failed += OnFailed;
}
// ── Observable properties ────────────────────────────────────────────────
/// <summary>Progress percentage (0100). Meaningful during Unlocking and test Running phases.</summary>
[ObservableProperty] private int _progress;
/// <summary>Leading label (localised "Auto Test").</summary>
[ObservableProperty] private string _typeLabel;
/// <summary>Current phase description shown in the snackbar.</summary>
[ObservableProperty] private string _phaseText;
/// <summary>Terminal result text — populated on Completed/Aborted.</summary>
[ObservableProperty] private string _resultText = string.Empty;
/// <summary>True once the sequence reaches Completed or Aborted.</summary>
[NotifyCanExecuteChangedFor(nameof(CloseCommand))]
[ObservableProperty] private bool _isComplete;
/// <summary>True while the Cancel button should be enabled (all non-terminal states).</summary>
[NotifyCanExecuteChangedFor(nameof(CancelCommand))]
[ObservableProperty] private bool _isCancellable;
/// <summary>Tri-state: null while running, true = success, false = failure.</summary>
[ObservableProperty] private bool? _isSuccess;
// ── Commands ─────────────────────────────────────────────────────────────
/// <summary>Cancels the orchestrator's current sequence.</summary>
[RelayCommand(CanExecute = nameof(IsCancellable))]
private void Cancel() => _orchestrator.Cancel();
/// <summary>Closes the snackbar (emits <see cref="RequestClose"/>).</summary>
[RelayCommand(CanExecute = nameof(IsComplete))]
private void Close() => RequestClose?.Invoke();
// ── Events ───────────────────────────────────────────────────────────────
/// <summary>Raised when the snackbar should close itself (after success / user dismiss).</summary>
public event Action? RequestClose;
// ── Orchestrator event handlers ──────────────────────────────────────────
private void OnStateChanged(AutoTestState state, string? detail)
{
Application.Current?.Dispatcher?.Invoke(() =>
{
switch (state)
{
case AutoTestState.Preflight:
PhaseText = _loc.GetString("AutoTest.State.Preflight");
break;
case AutoTestState.ConnectingKLine:
PhaseText = _loc.GetString("AutoTest.State.Connecting");
break;
case AutoTestState.ReadingPump:
PhaseText = _loc.GetString("AutoTest.State.Reading");
if (!string.IsNullOrEmpty(detail) && int.TryParse(detail, out int pct))
Progress = pct;
break;
case AutoTestState.Unlocking:
PhaseText = string.IsNullOrEmpty(detail)
? _loc.GetString("AutoTest.State.Unlocking")
: string.Format(_loc.GetString("AutoTest.State.UnlockingWithDetail"), detail);
break;
case AutoTestState.TurningOnBench:
PhaseText = _loc.GetString("AutoTest.State.BenchOn");
break;
case AutoTestState.StartingOilPump:
PhaseText = _loc.GetString("AutoTest.State.OilPump");
break;
case AutoTestState.StartingTest:
PhaseText = _loc.GetString("AutoTest.State.TestStart");
break;
case AutoTestState.Running:
PhaseText = string.IsNullOrEmpty(detail)
? _loc.GetString("AutoTest.State.Running")
: string.Format(_loc.GetString("AutoTest.State.RunningWithPhase"), detail);
break;
case AutoTestState.Completed:
PhaseText = _loc.GetString("AutoTest.State.Completed");
ResultText = PhaseText;
Progress = 100;
IsComplete = true;
IsCancellable = false;
IsSuccess = true;
break;
case AutoTestState.Aborted:
// Detail populated by OnFailed; still terminal.
IsComplete = true;
IsCancellable = false;
IsSuccess = false;
break;
}
});
}
private void OnFailed(AutoTestFailureReason reason, string message)
{
Application.Current?.Dispatcher?.Invoke(() =>
{
string key = "AutoTest.Failure." + reason;
string localised = _loc.GetString(key);
if (string.IsNullOrEmpty(localised) || localised == key)
localised = reason.ToString();
ResultText = string.IsNullOrEmpty(message)
? localised
: $"{localised}: {message}";
PhaseText = string.Format(_loc.GetString("AutoTest.State.Aborted"), ResultText);
IsComplete = true;
IsCancellable = false;
IsSuccess = false;
});
}
// ── IDisposable ──────────────────────────────────────────────────────────
/// <summary>Unsubscribes from orchestrator events.</summary>
public void Dispose()
{
_orchestrator.StateChanged -= OnStateChanged;
_orchestrator.Failed -= OnFailed;
}
}
}

View File

@@ -37,8 +37,16 @@ namespace HC_APTBS.ViewModels.Dialogs
_elapsedTime = "00:00";
_isCancellable = true;
_unlockService.StatusChanged += OnStatusChanged;
_unlockService.UnlockCompleted += OnUnlockCompleted;
_unlockService.StatusChanged += OnStatusChanged;
_unlockService.UnlockCompleted += OnUnlockCompleted;
_unlockService.PumpRelocked += OnPumpRelocked;
// PumpUnlocked fires as soon as the CAN TestUnlock parameter flips —
// regardless of which code path caused the unlock (fast unlock, Phase 1
// flood finishing, external manual unlock). This lets the dialog flip
// to its success state the instant the hardware confirms unlock, rather
// than waiting for UnlockService.UnlockAsync to reach its final
// verification step.
_unlockService.PumpUnlocked += OnPumpUnlocked;
}
// ── Observable properties ────────────────────────────────────────────────
@@ -69,6 +77,10 @@ namespace HC_APTBS.ViewModels.Dialogs
/// <summary>Tri-state result: null = in progress, true = success, false = failure.</summary>
[ObservableProperty] private bool? _isSuccess;
/// <summary>True when the pump is currently LOCKED and the operator can retry the unlock.</summary>
[NotifyCanExecuteChangedFor(nameof(RetryCommand))]
[ObservableProperty] private bool _canRetry;
// ── Commands ─────────────────────────────────────────────────────────────
/// <summary>Cancels the unlock sequence (only available during Phase 1).</summary>
@@ -89,11 +101,22 @@ namespace HC_APTBS.ViewModels.Dialogs
RequestClose?.Invoke();
}
/// <summary>Requests a new unlock attempt (only available when complete and pump is LOCKED).</summary>
[RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry()
{
CanRetry = false;
RequestRetry?.Invoke();
}
// ── Events ───────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event Action? RequestClose;
/// <summary>Raised when the operator presses Retry — parent should restart the unlock sequence.</summary>
public event Action? RequestRetry;
// ── Service event handlers ───────────────────────────────────────────────
private void OnStatusChanged(string msg)
@@ -137,6 +160,43 @@ namespace HC_APTBS.ViewModels.Dialogs
IsCancellable = false;
IsSuccess = success;
ResultText = success ? _loc.GetString("Dialog.Unlock.Unlocked") : _loc.GetString("Dialog.Unlock.Failed");
// Enable Retry when the unlock finished but the pump is still LOCKED.
CanRetry = !_unlockService.IsPumpUnlocked;
});
}
/// <summary>
/// Observer says the pump is now unlocked. Flip the dialog to the
/// success state immediately so the operator sees a responsive UI; the
/// later UnlockCompleted(true) event is idempotent and leaves this state
/// intact. If UnlockCompleted later arrives with failure=false, that
/// would overwrite — but that combination (observer unlocks then service
/// reports failure) is not a real scenario in the current state machine.
/// </summary>
private void OnPumpUnlocked()
{
Application.Current?.Dispatcher?.Invoke(() =>
{
if (IsComplete) return;
IsComplete = true;
IsCancellable = false;
IsSuccess = true;
CanRetry = false;
ResultText = _loc.GetString("Dialog.Unlock.Unlocked");
});
}
/// <summary>
/// Observer says the pump re-locked after a previously successful unlock.
/// If the snackbar is still visible (not dismissed), light up the Retry button
/// so the operator has a manual fallback without needing to reselect the pump.
/// </summary>
private void OnPumpRelocked()
{
Application.Current?.Dispatcher?.Invoke(() =>
{
if (IsComplete)
CanRetry = true;
});
}
@@ -145,8 +205,10 @@ namespace HC_APTBS.ViewModels.Dialogs
/// <summary>Unsubscribes from service events to prevent leaks.</summary>
public void Dispose()
{
_unlockService.StatusChanged -= OnStatusChanged;
_unlockService.StatusChanged -= OnStatusChanged;
_unlockService.UnlockCompleted -= OnUnlockCompleted;
_unlockService.PumpUnlocked -= OnPumpUnlocked;
_unlockService.PumpRelocked -= OnPumpRelocked;
}
}
}

View File

@@ -4,9 +4,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// Represents the vertical graphic result indicator for a single receive parameter
/// within a phase card. Displays expected value, tolerance bounds, and live/final
/// measurement result as a vertical progress bar.
/// Vertical min/max/target gauge for a single receive parameter on a phase card.
/// Ticks continuously while the phase is active (conditioning + measurement) and
/// locks to the final pass/fail colour via <see cref="IsPhaseCompleted"/> once the
/// phase ends.
/// </summary>
public sealed partial class GraphicIndicatorViewModel : ObservableObject
{
@@ -20,8 +21,9 @@ namespace HC_APTBS.ViewModels
[ObservableProperty] private double _tolerance;
/// <summary>
/// Current live measurement value, updated in real-time during the measurement phase.
/// Triggers recalculation of <see cref="ProgressPercent"/> and <see cref="IsWithinTolerance"/>.
/// Current live measurement value. Updated every refresh tick by
/// <see cref="TestPanelViewModel.UpdateLiveIndicator"/> so the bar moves
/// through conditioning as well as measurement.
/// </summary>
[ObservableProperty] private double _currentValue;
@@ -37,6 +39,12 @@ namespace HC_APTBS.ViewModels
/// <summary>True once a measurement has been recorded for this indicator.</summary>
[ObservableProperty] private bool _hasValue;
/// <summary>True after the owning phase completes; freezes the fill colour.</summary>
[ObservableProperty] private bool _isPhaseCompleted;
/// <summary>Pass/fail outcome of the owning phase. Only meaningful when <see cref="IsPhaseCompleted"/> is true.</summary>
[ObservableProperty] private bool _phasePassed;
/// <summary>Lower tolerance bound: <see cref="ExpectedValue"/> - <see cref="Tolerance"/>.</summary>
public double MinBound => ExpectedValue - Tolerance;
@@ -46,6 +54,15 @@ namespace HC_APTBS.ViewModels
/// <summary>Formatted display string for the current value.</summary>
public string DisplayValue => HasValue ? CurrentValue.ToString("F1") : "---";
/// <summary>Top of the in-tolerance band, as a percent of the bar height (0 = top, 100 = bottom).</summary>
public double ToleranceBandTopPercent => Tolerance > 0 ? 20.0 : 50.0;
/// <summary>Height of the in-tolerance band, as a percent of the bar height.</summary>
public double ToleranceBandHeightPercent => Tolerance > 0 ? 60.0 : 0.0;
/// <summary>Position of the target line, as a percent of the bar height (symmetric around expected).</summary>
public double ExpectedMarkerPercent => 50.0;
// ── Recalculation on value change ─────────────────────────────────────────
partial void OnCurrentValueChanged(double value)
@@ -67,9 +84,22 @@ namespace HC_APTBS.ViewModels
{
OnPropertyChanged(nameof(MinBound));
OnPropertyChanged(nameof(MaxBound));
OnPropertyChanged(nameof(ToleranceBandTopPercent));
OnPropertyChanged(nameof(ToleranceBandHeightPercent));
if (HasValue) RecalculateProgress(CurrentValue);
}
/// <summary>
/// Applies a runtime tolerance update (e.g. after DFI auto-adjust) without
/// touching the live <see cref="CurrentValue"/>. Raises change notifications
/// for all dependent computed properties.
/// </summary>
public void ApplyTolerance(double expected, double tolerance)
{
ExpectedValue = expected;
Tolerance = tolerance;
}
/// <summary>
/// Computes the progress bar fill percentage using the same algorithm as the
/// original GraphicResultDisplay. The display range extends 20% beyond the
@@ -108,6 +138,8 @@ namespace HC_APTBS.ViewModels
ProgressPercent = 0;
IsWithinTolerance = true;
HasValue = false;
IsPhaseCompleted = false;
PhasePassed = false;
OnPropertyChanged(nameof(DisplayValue));
}
}

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

View File

@@ -1,6 +1,4 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -42,10 +40,28 @@ namespace HC_APTBS.ViewModels.Pages
/// <summary>True when this device is the currently active connection.</summary>
[ObservableProperty] private bool _isConnected;
/// <summary>True when the device session is in a failed state (K-Line only).</summary>
[ObservableProperty] private bool _isFailed;
/// <summary>False for the Bench placeholder, which cannot be clicked.</summary>
public bool IsEnabled { get; init; } = true;
}
/// <summary>
/// Snackbar state for an in-flight CAN connect/disconnect transition.
/// </summary>
public sealed partial class DeviceTransitionViewModel : ObservableObject
{
/// <summary>Localised message shown to the user.</summary>
[ObservableProperty] private string _message = "";
/// <summary>True while the operation is running (spinner visible).</summary>
[ObservableProperty] private bool _isBusy;
/// <summary>Null while running; true on success; false on failure.</summary>
[ObservableProperty] private bool? _isSuccess;
}
/// <summary>
/// ViewModel for the Devices column on the Dashboard.
///
@@ -69,6 +85,9 @@ namespace HC_APTBS.ViewModels.Pages
/// <summary>Single bench-controller placeholder row.</summary>
public ObservableCollection<DeviceItem> BenchDevices { get; } = new();
/// <summary>Active snackbar VM for an in-flight CAN connect/disconnect; null when no transition.</summary>
[ObservableProperty] private DeviceTransitionViewModel? _transition;
public DashboardDevicesViewModel(MainViewModel root, ICanService can, IKwpService kwp)
{
_root = root;
@@ -136,7 +155,9 @@ namespace HC_APTBS.ViewModels.Pages
? dev.SerialNumber
: $"{dev.Description} ({dev.SerialNumber})",
IsConnected = connected,
IsFailed = failed,
StateLabel = GetKLineStateLabel(connected, failed),
IsEnabled = false, // K-Line rows are display-only; session is owned by AutoTestOrchestrator
});
}
}
@@ -168,62 +189,74 @@ namespace HC_APTBS.ViewModels.Pages
{
if (item is null || !item.IsEnabled) return;
bool testRunning = _root.IsTestRunning;
bool sessionActive = item.IsConnected;
// K-Line rows are non-clickable (IsEnabled=false), so they never reach this point.
// Sessions for K-Line are started/stopped exclusively by AutoTestOrchestrator.
if (item.Kind != DeviceKind.Can) return;
if (testRunning)
{
if (!Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
return;
}
else if (sessionActive)
{
string body = string.Format(Str("Devices.Confirm.Body.Active"),
item.Kind == DeviceKind.Can ? "CAN" : "K-Line");
if (!Confirm(Str("Devices.Confirm.Title"), body))
return;
}
// A running test owns the CAN bus; never let the user yank it mid-run.
if (_root.IsTestRunning &&
!Confirm(Str("Devices.Confirm.Title"), Str("Devices.Confirm.Body.TestRunning")))
return;
switch (item.Kind)
{
case DeviceKind.Can:
await ToggleCanAsync(item);
break;
case DeviceKind.KLine:
await ToggleKLineAsync(item);
break;
}
await ToggleCanAsync(item);
}
private async Task ToggleCanAsync(DeviceItem item)
{
if (item.IsConnected)
{
_root.DisconnectCanCommand.Execute(null);
}
else
{
try { _can.SelectedChannel = item.CanHandle; }
catch { return; }
_root.ConnectCanCommand.Execute(null);
}
await Task.Delay(600); // allow liveness event propagation
RefreshCanDevices();
}
bool connecting = !item.IsConnected;
private async Task ToggleKLineAsync(DeviceItem item)
{
if (item.IsConnected)
var t = new DeviceTransitionViewModel
{
_kwp.Disconnect();
}
else
Message = Str(connecting
? "Dashboard.Devices.Snackbar.Connecting"
: "Dashboard.Devices.Snackbar.Disconnecting"),
IsBusy = true,
};
Transition = t;
// ConnectCan/DisconnectCan mutate observable properties that fan out to
// CanExecuteChanged listeners on Buttons — those DPs are UI-thread-affine,
// so the commands must run on the Dispatcher even though they block briefly
// on the PCAN handle. Yield once so the snackbar paints before we block.
await Task.Yield();
bool ok;
try
{
try { await _kwp.ConnectAsync(item.Id, CancellationToken.None); }
catch { /* ConnectAsync throws on init failure — leave state as-is */ }
if (connecting)
{
try { _can.SelectedChannel = item.CanHandle; }
catch { Transition = null; return; }
_root.ConnectCanCommand.Execute(null);
ok = _root.IsCanConnected;
}
else
{
_root.DisconnectCanCommand.Execute(null);
ok = !_root.IsCanConnected;
}
}
RefreshKLineDevices();
catch
{
ok = false;
}
t.IsBusy = false;
t.IsSuccess = ok;
t.Message = Str(ok
? (connecting ? "Dashboard.Devices.Snackbar.Connected"
: "Dashboard.Devices.Snackbar.Disconnected")
: "Dashboard.Devices.Snackbar.Failed");
RefreshCanDevices();
// Auto-dismiss after ~2 s; only clear if a fresh transition has not replaced this one.
_ = Task.Delay(2000).ContinueWith(_ =>
App.Current.Dispatcher.Invoke(() =>
{
if (Transition == t) Transition = null;
}));
}
// ── State change wiring ───────────────────────────────────────────────────

View File

@@ -0,0 +1,268 @@
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();
}
}
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.ObjectModel;
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>
/// Developer Tools — saved KWP custom commands library. Wraps the persistence
/// surface in <see cref="IConfigurationService"/>, exposes commands to send the
/// selected entry, save the parent's current hex input as a new entry, and
/// delete entries.
///
/// <para>Compiled into Debug builds only — see <c>HC_APTBS.csproj</c>.</para>
/// </summary>
public sealed partial class DeveloperToolsCommandsViewModel : ObservableObject
{
private readonly DeveloperPageViewModel _parent;
private readonly IKwpService _kwp;
private readonly IConfigurationService _config;
private readonly IAppLogger _log;
private const string LogId = nameof(DeveloperToolsCommandsViewModel);
public DeveloperToolsCommandsViewModel(
DeveloperPageViewModel parent,
IKwpService kwp,
IConfigurationService config,
IAppLogger log)
{
_parent = parent;
_kwp = kwp;
_config = config;
_log = log;
}
/// <summary>Persistent collection from <see cref="IConfigurationService.CustomCommands"/>.</summary>
public ObservableCollection<CustomCommand> Items => _config.CustomCommands;
/// <summary>Currently selected list entry. Drives Send / Delete enable state.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SendSelectedCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))]
private CustomCommand? _selected;
/// <summary>Name typed into the "Save current as…" input.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCurrentCommand))]
private string _newName = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Sends the selected library entry over the persistent K-Line session.</summary>
[RelayCommand(CanExecute = nameof(CanSendSelected))]
private async Task SendSelectedAsync()
{
if (Selected is null) return;
if (!DeveloperPageViewModel.TryParseHex(Selected.HexBytes, out var bytes, out var error))
{
_parent.SetStatus($"'{Selected.Name}' has invalid hex: {error}");
return;
}
var hex = DeveloperPageViewModel.FormatHex(bytes);
_parent.AppendLog(DeveloperLogDirection.Tx, $"[{Selected.Name}] {hex}");
_parent.SetStatus($"Sending '{Selected.Name}' ({bytes.Length} byte(s))…");
try
{
var responses = await _kwp.SendRawCustomAsync(bytes, CancellationToken.None);
if (responses.Count == 0)
{
_parent.AppendLog(DeveloperLogDirection.Info, "(no response)");
_parent.SetStatus("No response packets.");
return;
}
foreach (var pkt in responses)
_parent.AppendLog(DeveloperLogDirection.Rx, DeveloperPageViewModel.FormatHex(pkt));
_parent.SetStatus($"Received {responses.Count} packet(s).");
}
catch (Exception ex)
{
_parent.AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}");
_parent.SetStatus($"Send failed: {ex.Message}");
_log.Warning(LogId, $"SendSelected failed: {ex.Message}");
}
}
private bool CanSendSelected() => Selected is not null;
/// <summary>
/// Saves the parent VM's current <c>HexInput</c> as a new entry under
/// <see cref="NewName"/>. Validates that hex parses before persisting.
/// </summary>
[RelayCommand(CanExecute = nameof(CanSaveCurrent))]
private void SaveCurrent()
{
var name = (NewName ?? string.Empty).Trim();
if (name.Length == 0)
{
_parent.SetStatus("Enter a name before saving the current command.");
return;
}
var hex = (_parent.HexInput ?? string.Empty).Trim();
if (!DeveloperPageViewModel.TryParseHex(hex, out var bytes, out var error))
{
_parent.SetStatus($"Cannot save '{name}': {error}");
return;
}
var normalized = DeveloperPageViewModel.FormatHex(bytes);
Items.Add(new CustomCommand { Name = name, HexBytes = normalized });
_config.SaveCustomCommands();
_parent.AppendLog(DeveloperLogDirection.Info, $"SAVED command '{name}' = {normalized}");
_parent.SetStatus($"Saved '{name}'.");
NewName = string.Empty;
}
private bool CanSaveCurrent() => !string.IsNullOrWhiteSpace(NewName);
/// <summary>Removes the selected entry from the library and persists.</summary>
[RelayCommand(CanExecute = nameof(CanDeleteSelected))]
private void DeleteSelected()
{
if (Selected is null) return;
var name = Selected.Name;
Items.Remove(Selected);
_config.SaveCustomCommands();
_parent.AppendLog(DeveloperLogDirection.Info, $"DELETED command '{name}'");
_parent.SetStatus($"Deleted '{name}'.");
Selected = null;
}
private bool CanDeleteSelected() => Selected is not null;
}
}

View File

@@ -0,0 +1,366 @@
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);
}
}
}

View File

@@ -0,0 +1,191 @@
using System;
using System.Collections.ObjectModel;
using System.Globalization;
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>
/// Developer Tools — EEPROM unlock password library. Persisted via
/// <see cref="IConfigurationService.EepromPasswords"/>.
///
/// <para>"Apply" sends the standard KWP unlock packet
/// <c>[0x18 0x00 Zone KeyHi KeyLo]</c> over the persistent K-Line session
/// (per <c>docs/kline_eeprom_spec.md</c>) and logs ACK/NAK in the parent's
/// transaction log. "Add" pushes a new entry built from the small inline editor.</para>
///
/// <para>Compiled into Debug builds only — see <c>HC_APTBS.csproj</c>.</para>
/// </summary>
public sealed partial class DeveloperToolsPasswordsViewModel : ObservableObject
{
private readonly DeveloperPageViewModel _parent;
private readonly IKwpService _kwp;
private readonly IConfigurationService _config;
private readonly IAppLogger _log;
private const string LogId = nameof(DeveloperToolsPasswordsViewModel);
public DeveloperToolsPasswordsViewModel(
DeveloperPageViewModel parent,
IKwpService kwp,
IConfigurationService config,
IAppLogger log)
{
_parent = parent;
_kwp = kwp;
_config = config;
_log = log;
}
/// <summary>Persistent collection from <see cref="IConfigurationService.EepromPasswords"/>.</summary>
public ObservableCollection<EepromPassword> Items => _config.EepromPasswords;
/// <summary>Currently selected entry. Drives Apply / Delete enable state.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ApplySelectedCommand))]
[NotifyCanExecuteChangedFor(nameof(DeleteSelectedCommand))]
private EepromPassword? _selected;
// ── Inline "Add new" editor ──────────────────────────────────────────────
/// <summary>Display name for the new entry.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string _newName = string.Empty;
/// <summary>Zone byte for the new entry, hex (e.g. "03").</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string _newZoneHex = "00";
/// <summary>16-bit key for the new entry, hex (e.g. "00FF").</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string _newKeyHex = "0000";
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Sends <c>[0x18 0x00 Zone KeyHi KeyLo]</c> for the selected password.</summary>
[RelayCommand(CanExecute = nameof(CanApplySelected))]
private async Task ApplySelectedAsync()
{
if (Selected is null) return;
byte[] payload =
{
0x18,
0x00,
Selected.Zone,
(byte)(Selected.Key >> 8),
(byte)(Selected.Key & 0xFF),
};
var hex = DeveloperPageViewModel.FormatHex(payload);
_parent.AppendLog(DeveloperLogDirection.Tx, $"[Apply '{Selected.Name}'] {hex}");
_parent.SetStatus($"Applying '{Selected.Name}' (zone 0x{Selected.Zone:X2}, key 0x{Selected.Key:X4})…");
try
{
var responses = await _kwp.SendRawCustomAsync(payload, CancellationToken.None);
if (responses.Count == 0)
{
_parent.AppendLog(DeveloperLogDirection.Info, "(no response)");
_parent.SetStatus("No response — session may not be open.");
return;
}
foreach (var pkt in responses)
_parent.AppendLog(DeveloperLogDirection.Rx, DeveloperPageViewModel.FormatHex(pkt));
// Heuristic: a NAK is a single 3-byte packet with title 0x0A;
// anything else is treated as success at the protocol layer.
bool nak = responses.Count == 1 && responses[0].Length >= 3 && responses[0][2] == 0x0A;
_parent.SetStatus(nak
? $"'{Selected.Name}' rejected (NAK)."
: $"'{Selected.Name}' accepted.");
}
catch (Exception ex)
{
_parent.AppendLog(DeveloperLogDirection.Info, $"ERROR: {ex.Message}");
_parent.SetStatus($"Apply failed: {ex.Message}");
_log.Warning(LogId, $"ApplySelected failed: {ex.Message}");
}
}
private bool CanApplySelected() => Selected is not null;
/// <summary>Adds a new entry from the inline editor and persists.</summary>
[RelayCommand(CanExecute = nameof(CanAdd))]
private void Add()
{
var name = (NewName ?? string.Empty).Trim();
if (name.Length == 0)
{
_parent.SetStatus("Enter a name before adding a password.");
return;
}
if (!TryParseHexByte(NewZoneHex, out byte zone))
{
_parent.SetStatus($"Bad zone hex '{NewZoneHex}'.");
return;
}
if (!TryParseHexUshort(NewKeyHex, out ushort key))
{
_parent.SetStatus($"Bad key hex '{NewKeyHex}'.");
return;
}
Items.Add(new EepromPassword { Name = name, Zone = zone, Key = key });
_config.SaveEepromPasswords();
_parent.AppendLog(DeveloperLogDirection.Info,
$"ADDED password '{name}' (zone 0x{zone:X2}, key 0x{key:X4})");
_parent.SetStatus($"Added '{name}'.");
NewName = string.Empty;
NewZoneHex = "00";
NewKeyHex = "0000";
}
private bool CanAdd() =>
!string.IsNullOrWhiteSpace(NewName)
&& TryParseHexByte(NewZoneHex, out _)
&& TryParseHexUshort(NewKeyHex, out _);
/// <summary>Removes the selected entry from the library and persists.</summary>
[RelayCommand(CanExecute = nameof(CanDeleteSelected))]
private void DeleteSelected()
{
if (Selected is null) return;
var name = Selected.Name;
Items.Remove(Selected);
_config.SaveEepromPasswords();
_parent.AppendLog(DeveloperLogDirection.Info, $"DELETED password '{name}'");
_parent.SetStatus($"Deleted '{name}'.");
Selected = null;
}
private bool CanDeleteSelected() => Selected is not null;
// ── Helpers ───────────────────────────────────────────────────────────────
private static bool TryParseHexByte(string text, out byte value)
{
value = 0;
if (string.IsNullOrWhiteSpace(text)) return false;
var t = text.Trim();
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
return byte.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
}
private static bool TryParseHexUshort(string text, out ushort value)
{
value = 0;
if (string.IsNullOrWhiteSpace(text)) return false;
var t = text.Trim();
if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t.Substring(2);
return ushort.TryParse(t, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
}
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
using HC_APTBS.ViewModels.Dialogs;
@@ -40,12 +42,17 @@ namespace HC_APTBS.ViewModels.Pages
/// <summary>Second pump status display — Empf3 word.</summary>
public StatusDisplayViewModel StatusDisplay2 => Root.StatusDisplay2;
/// <summary>BIP-STATUS display (PSG5-PI pumps only; hidden via HasDefinition for others).</summary>
public BipDisplayViewModel BipDisplay => Root.BipDisplay;
/// <summary>Current immobilizer unlock VM. Null when no unlock is in progress.</summary>
public UnlockProgressViewModel? UnlockVm => Root.CurrentUnlockVm;
/// <summary>Real-time RPM chart (120-sample rolling window).</summary>
public SingleFlowChartViewModel RpmChart { get; }
private readonly DispatcherTimer _rpmChartTimer;
// ── Banner flags (derived from Root state) ────────────────────────────────
/// <summary>True when a pump has been loaded from the database.</summary>
@@ -67,14 +74,38 @@ namespace HC_APTBS.ViewModels.Pages
{
Root = root;
DtcList = dtcList;
RpmChart = new SingleFlowChartViewModel("RPM", new SKColor(0x21, 0x96, 0xF3), maxSamples: 120);
RpmChart = new SingleFlowChartViewModel(
"RPM",
new SKColor(0x21, 0x96, 0xF3),
maxSamples: 120,
smoothScroll: true);
_rpmChartTimer = new DispatcherTimer
{
Interval = HzToInterval(root.Config.Settings.RpmChartUpdateHz)
};
_rpmChartTimer.Tick += (_, _) => RpmChart.AddValue(Root.PumpRpm);
_rpmChartTimer.Start();
// Per-frame viewport slide — produces smooth continuous leftward motion independent
// of data cadence. Safe to leave subscribed for the VM's lifetime (one DateTime.UtcNow
// and two property sets per render frame).
CompositionTarget.Rendering += OnRenderFrame;
RefreshDerivedFlags();
Root.PropertyChanged += OnRootPropertyChanged;
Root.SettingsSaved += OnSettingsSaved;
Root.PumpIdentification.PumpChanged += _ => RefreshDerivedFlags();
}
private void OnRenderFrame(object? sender, EventArgs e)
{
int hz = Root.Config.Settings.RpmChartUpdateHz;
if (hz <= 0) hz = 15;
RpmChart.UpdateViewport(DateTime.UtcNow, 1000.0 / hz);
}
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
@@ -87,13 +118,17 @@ namespace HC_APTBS.ViewModels.Pages
case nameof(MainViewModel.CurrentUnlockVm):
OnPropertyChanged(nameof(UnlockVm));
break;
case nameof(MainViewModel.PumpRpm):
Application.Current.Dispatcher.Invoke(() => RpmChart.AddValue(Root.PumpRpm));
break;
}
}
private void OnSettingsSaved()
{
_rpmChartTimer.Interval = HzToInterval(Root.Config.Settings.RpmChartUpdateHz);
}
private static System.TimeSpan HzToInterval(int hz) =>
System.TimeSpan.FromMilliseconds(hz > 0 ? 1000.0 / hz : 1000.0 / 15);
private void RefreshDerivedFlags()
{
IsPumpSelected = Root.CurrentPump != null;

View File

@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Windows;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Infrastructure.Logging;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
@@ -21,6 +22,7 @@ namespace HC_APTBS.ViewModels.Pages
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private readonly IAppLogger _log;
/// <summary>
/// Raised after <see cref="SaveCommand"/> successfully persists settings.
@@ -54,6 +56,12 @@ namespace HC_APTBS.ViewModels.Pages
[ObservableProperty] private double _tolerancePfpExtension = 0.1;
[ObservableProperty] private bool _defaultIgnoreTin = true;
/// <summary>
/// When true, the Dashboard "Connect &amp; Auto Test" flow bypasses the oil-pump
/// leak-check dialog. Operator opts in once; does not affect manual controls.
/// </summary>
[ObservableProperty] private bool _autoTestSkipsOilPumpConfirm;
// ── PID ───────────────────────────────────────────────────────────────
[ObservableProperty] private double _pidP = 0.1;
@@ -87,15 +95,18 @@ namespace HC_APTBS.ViewModels.Pages
[ObservableProperty] private int _refreshPumpParamsMs = 4;
[ObservableProperty] private int _blinkIntervalMs = 1000;
[ObservableProperty] private int _flasherIntervalMs = 800;
[ObservableProperty] private int _rpmChartUpdateHz = 15;
// ── Constructor ───────────────────────────────────────────────────────
/// <param name="configService">Configuration service for loading/saving settings.</param>
/// <param name="localizationService">Localization service for language switching.</param>
public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService)
/// <param name="logger">Application logger.</param>
public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService, IAppLogger logger)
{
_config = configService;
_loc = localizationService;
_log = logger;
LoadFromConfig();
EnumerateFtdiDevices();
@@ -107,6 +118,7 @@ namespace HC_APTBS.ViewModels.Pages
[RelayCommand]
private void Save()
{
_log.Info("SETTINGSVM", $"Save invoked. SelectedLanguage='{SelectedLanguage}', loc.CurrentLanguage='{_loc.CurrentLanguage}'");
var s = _config.Settings;
// General
@@ -120,6 +132,7 @@ namespace HC_APTBS.ViewModels.Pages
s.ToleranceUpExtension = ToleranceUpExtension;
s.TolerancePfpExtension = TolerancePfpExtension;
s.DefaultIgnoreTin = DefaultIgnoreTin;
s.AutoTestSkipsOilPumpConfirm = AutoTestSkipsOilPumpConfirm;
// PID
s.PidP = PidP;
@@ -150,6 +163,7 @@ namespace HC_APTBS.ViewModels.Pages
s.RefreshPumpParamsMs = RefreshPumpParamsMs;
s.BlinkIntervalMs = BlinkIntervalMs;
s.FlasherIntervalMs = FlasherIntervalMs;
s.RpmChartUpdateHz = RpmChartUpdateHz;
// Language — switch if changed (also persists via LocalizationService)
if (SelectedLanguage != _loc.CurrentLanguage)
@@ -259,6 +273,7 @@ namespace HC_APTBS.ViewModels.Pages
ToleranceUpExtension = s.ToleranceUpExtension;
TolerancePfpExtension = s.TolerancePfpExtension;
DefaultIgnoreTin = s.DefaultIgnoreTin;
AutoTestSkipsOilPumpConfirm = s.AutoTestSkipsOilPumpConfirm;
// PID
PidP = s.PidP;
@@ -288,6 +303,7 @@ namespace HC_APTBS.ViewModels.Pages
RefreshPumpParamsMs = s.RefreshPumpParamsMs;
BlinkIntervalMs = s.BlinkIntervalMs;
FlasherIntervalMs = s.FlasherIntervalMs;
RpmChartUpdateHz = s.RpmChartUpdateHz;
// Deep-copy the RPM-voltage relation table
Relations.Clear();

View File

@@ -1,55 +1,38 @@
using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>Wrapper VM exposing <see cref="TestPanel"/> when the wizard is in the Plan step.</summary>
public sealed class PlanStateViewModel
{
/// <summary>Shared test panel (enable/disable phases).</summary>
public TestPanelViewModel TestPanel { get; }
/// <summary>Creates a new Plan-state wrapper around the shared test panel.</summary>
public PlanStateViewModel(TestPanelViewModel testPanel) => TestPanel = testPanel;
}
/// <summary>Wrapper VM exposing <see cref="TestPanel"/> when the wizard is in the Running step.</summary>
public sealed class RunningStateViewModel
{
/// <summary>Shared test panel (live phase updates).</summary>
public TestPanelViewModel TestPanel { get; }
/// <summary>Creates a new Running-state wrapper around the shared test panel.</summary>
public RunningStateViewModel(TestPanelViewModel testPanel) => TestPanel = testPanel;
}
/// <summary>
/// Orchestrator view-model for the Tests navigation page.
/// Single-page Tests orchestrator.
///
/// <para>Drives the <c>Plan → Preconditions → Running → Done</c> wizard defined in
/// <c>docs/ui-structure.md §4</c>. Exposes <see cref="CurrentStateVm"/>, which the
/// view's <c>ContentControl</c> routes through typed DataTemplates to the four
/// step views. Commands (<see cref="NextCommand"/>, <see cref="BackCommand"/>,
/// <see cref="AbortCommand"/>, <see cref="RunAgainCommand"/>,
/// <see cref="ViewFullResultsCommand"/>) form the wizard's state-machine edges.</para>
///
/// <para>Observes <see cref="MainViewModel.IsTestRunning"/> to perform the
/// Preconditions→Running (on true) and Running→Done (on false) transitions
/// automatically, so the page stays in sync regardless of which control fired
/// the underlying start/stop command.</para>
/// <para>Replaces the former Plan → Preconditions → Running → Done wizard with
/// one status-bar-driven page. The body is always the test-section cards; the
/// status bar narrates the current state (idle / blocked / running / complete)
/// and the action bar exposes the contextual primary action (Start, Abort, or
/// Report + Clear). A PASSED / FAILED snackbar overlay auto-dismisses a few
/// seconds after the test finishes; the Report / Clear-data buttons stay
/// available on the action bar until the operator clears the results.</para>
/// </summary>
public sealed partial class TestsPageViewModel : ObservableObject
{
private const int SnackbarDurationMs = 5000;
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private DispatcherTimer? _snackbarTimer;
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
@@ -59,119 +42,101 @@ namespace HC_APTBS.ViewModels.Pages
/// <summary>Measurement results table (per-phase pass/fail).</summary>
public ResultDisplayViewModel ResultDisplay => Root.ResultDisplay;
/// <summary>Preconditions checklist — lazily instantiated on first entry into the step.</summary>
public TestPreconditionsViewModel Preconditions { get; }
/// <summary>Auth gate scoped to the Tests page (used by preconditions for auth-required tests).</summary>
/// <summary>Auth gate scoped to the Tests page (used when an enabled test has <c>RequiresAuth</c>).</summary>
public AuthGateViewModel TestAuth { get; }
private readonly PlanStateViewModel _planVm;
private readonly RunningStateViewModel _runningVm;
/// <summary>
/// Creates the Tests page orchestrator.
/// </summary>
/// <param name="root">Root coordinator.</param>
/// <param name="config">Configuration service (passed to the scoped auth gate).</param>
/// <param name="loc">Localisation service.</param>
/// <summary>Creates the Tests page orchestrator.</summary>
public TestsPageViewModel(MainViewModel root, IConfigurationService config, ILocalizationService loc)
{
Root = root;
_config = config;
_loc = loc;
TestAuth = new AuthGateViewModel(config, loc);
Preconditions = new TestPreconditionsViewModel(root, loc, Root.TestPanel, TestAuth);
_planVm = new PlanStateViewModel(Root.TestPanel);
_runningVm = new RunningStateViewModel(Root.TestPanel);
TestAuth = new AuthGateViewModel(config, loc);
CurrentStateVm = _planVm;
Root.PropertyChanged += OnRootPropertyChanged;
Root.DashboardAlarms.PropertyChanged += OnAlarmsPropertyChanged;
Root.TestPanel.PropertyChanged += OnTestPanelPropertyChanged;
Root.TestPanel.Tests.CollectionChanged += OnTestSectionsChanged;
TestAuth.PropertyChanged += OnAuthPropertyChanged;
_loc.LanguageChanged += OnLanguageChanged;
Root.PropertyChanged += OnRootPropertyChanged;
RebindPhaseEnabledWatchers();
RefreshAuthRequired();
Reevaluate();
}
// ── State ─────────────────────────────────────────────────────────────────
// ── Runtime state ─────────────────────────────────────────────────────────
/// <summary>Current wizard step.</summary>
/// <summary>Mirrors <see cref="MainViewModel.IsTestRunning"/>.</summary>
public bool IsTestRunning => Root.IsTestRunning;
/// <summary>Latched after a test completes; gates Report / Clear-data buttons.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(AbortCommand))]
[NotifyCanExecuteChangedFor(nameof(RunAgainCommand))]
[NotifyCanExecuteChangedFor(nameof(ViewFullResultsCommand))]
private TestFlowState _currentState = TestFlowState.Plan;
[NotifyCanExecuteChangedFor(nameof(ClearTestDataCommand))]
[NotifyPropertyChangedFor(nameof(ShowDoneSnackbar))]
private bool _hasCompletedResults;
/// <summary>
/// View-model currently rendered by the step <c>ContentControl</c>. Swaps to
/// <see cref="PlanStateViewModel"/>, <see cref="TestPreconditionsViewModel"/>,
/// <see cref="RunningStateViewModel"/>, or <c>this</c> (for the Done step).
/// </summary>
[ObservableProperty] private object _currentStateVm;
/// <summary>True if the most recent completed run passed.</summary>
[ObservableProperty] private bool _lastRunPassed;
/// <summary>Convenience flag for view styling — true while a test is actively running.</summary>
public bool IsRunningStep => CurrentState == TestFlowState.Running;
/// <summary>Auto-dismiss flag for the Done snackbar overlay.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowDoneSnackbar))]
private bool _isSnackbarVisible;
/// <summary>Convenience flag for view styling — true when the page is on the Done step.</summary>
public bool IsDoneStep => CurrentState == TestFlowState.Done;
/// <summary>True while the PASSED / FAILED snackbar overlay is visible.</summary>
public bool ShowDoneSnackbar => HasCompletedResults && IsSnackbarVisible;
partial void OnCurrentStateChanged(TestFlowState oldValue, TestFlowState newValue)
{
if (oldValue == TestFlowState.Preconditions && newValue != TestFlowState.Preconditions)
Preconditions.Deactivate();
// ── Preconditions ─────────────────────────────────────────────────────────
switch (newValue)
{
case TestFlowState.Plan:
CurrentStateVm = _planVm;
break;
case TestFlowState.Preconditions:
Preconditions.Activate();
Preconditions.OnEnabledPhasesChanged();
CurrentStateVm = Preconditions;
break;
case TestFlowState.Running:
CurrentStateVm = _runningVm;
break;
case TestFlowState.Done:
CurrentStateVm = this;
break;
}
/// <summary>True when at least one enabled test requires operator authentication.</summary>
[ObservableProperty] private bool _isAuthRequired;
OnPropertyChanged(nameof(IsRunningStep));
OnPropertyChanged(nameof(IsDoneStep));
}
/// <summary>True when every required precondition passes.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartTestCommand))]
private bool _allPreconditionsPassed;
/// <summary>Localised blocker message shown on the status bar when Start is not available.</summary>
[ObservableProperty] private string _blockingReason = string.Empty;
/// <summary>Localised headline shown on the status bar (state summary).</summary>
[ObservableProperty] private string _statusHeadline = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>
/// Advances Plan → Preconditions. No-op if no phases are enabled (defensive — the
/// Preconditions step would fail its auth-detection anyway so there's nothing to
/// start). The phase-enable state is not observed live, so the button itself is
/// only guarded by <see cref="CurrentState"/>.
/// Starts the test sequence. Mirrors the former Preconditions step: if the oil
/// pump is off, turning it on triggers the leak-check confirmation dialog; a
/// cancelled confirmation aborts the start.
/// </summary>
[RelayCommand(CanExecute = nameof(CanNext))]
private void Next()
[RelayCommand(CanExecute = nameof(CanStart))]
private async Task StartTestAsync()
{
if (CurrentState != TestFlowState.Plan) return;
if (!Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled))) return;
CurrentState = TestFlowState.Preconditions;
if (!Root.BenchControl.IsOilPumpOn)
{
// Setter shows OilPumpConfirmDialog; reverts on cancel.
// BenchControlViewModel.OnIsOilPumpOnChanged re-asserts the
// backing field after SetRelay so the guard below reflects
// the user's choice even if RefreshFromTick fired during
// the dialog's nested dispatcher pump.
Root.BenchControl.IsOilPumpOn = true;
if (!Root.BenchControl.IsOilPumpOn)
return;
}
// CanStart already gated on every precondition (pump/CAN/alarms/auth/
// phases), so bypass Root.StartTestCommand.CanExecute — it can report
// false transiently right after the oil-pump relay toggles — and drive
// the async command directly. Awaited so any exception surfaces on
// the command's ExecutionTask instead of being swallowed.
await Root.StartTestCommand.ExecuteAsync(null);
}
private bool CanNext() => CurrentState == TestFlowState.Plan;
private bool CanStart() => AllPreconditionsPassed && !IsTestRunning;
/// <summary>Goes back Preconditions → Plan. Disabled during Running / Done.</summary>
[RelayCommand(CanExecute = nameof(CanBack))]
private void Back()
{
if (CurrentState == TestFlowState.Preconditions)
CurrentState = TestFlowState.Plan;
}
private bool CanBack() => CurrentState == TestFlowState.Preconditions;
/// <summary>
/// Opens a confirmation dialog and, if accepted, delegates to <see cref="MainViewModel.StopTestCommand"/>.
/// </summary>
/// <summary>Confirms then delegates to <see cref="MainViewModel.StopTestCommand"/>.</summary>
[RelayCommand(CanExecute = nameof(CanAbort))]
private void Abort()
{
@@ -190,44 +155,208 @@ namespace HC_APTBS.ViewModels.Pages
Root.StopTestCommand.Execute(null);
}
private bool CanAbort() => CurrentState == TestFlowState.Running;
private bool CanAbort() => IsTestRunning;
/// <summary>Resets the page for a fresh run without reloading the pump.</summary>
[RelayCommand(CanExecute = nameof(CanRunAgain))]
private void RunAgain()
/// <summary>Resets phase results and hides post-test affordances.</summary>
[RelayCommand(CanExecute = nameof(CanClearTestData))]
private void ClearTestData()
{
Root.TestPanel.ResetResults();
Root.ResultDisplay.Clear();
CurrentState = TestFlowState.Plan;
HasCompletedResults = false;
IsSnackbarVisible = false;
StopSnackbarTimer();
Reevaluate();
}
private bool CanRunAgain() => CurrentState == TestFlowState.Done;
private bool CanClearTestData() => HasCompletedResults && !IsTestRunning;
/// <summary>Jumps to the Results navigation page.</summary>
[RelayCommand(CanExecute = nameof(CanViewFullResults))]
private void ViewFullResults()
/// <summary>Dismisses the pass/fail snackbar overlay (Report / Clear remain on the action bar).</summary>
[RelayCommand]
private void DismissSnackbar()
{
Root.SelectedPage = AppPage.Results;
IsSnackbarVisible = false;
StopSnackbarTimer();
}
private bool CanViewFullResults() => CurrentState == TestFlowState.Done;
// ── Evaluation ────────────────────────────────────────────────────────────
// ── IsTestRunning → wizard state sync ─────────────────────────────────────
private void Reevaluate()
{
bool hasPump = Root.CurrentPump != null;
bool hasCan = Root.IsCanConnected;
bool noAlarms = !Root.DashboardAlarms.HasCritical;
bool authOk = !IsAuthRequired || TestAuth.IsAuthenticated;
bool anyPhase = Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled));
string blocker =
!hasPump ? _loc.GetString("Test.Precheck.Remediation.SelectPump") :
!hasCan ? _loc.GetString("Test.Precheck.Remediation.CheckCan") :
!noAlarms ? _loc.GetString("Test.Precheck.Remediation.ClearAlarms") :
!authOk ? _loc.GetString("Test.Precheck.Remediation.Authenticate") :
!anyPhase ? _loc.GetString("Test.Status.NoPhases") :
string.Empty;
BlockingReason = blocker;
AllPreconditionsPassed = blocker.Length == 0;
RefreshStatusHeadline();
}
private void RefreshStatusHeadline()
{
if (IsTestRunning)
StatusHeadline = _loc.GetString("Test.Status.Running");
else if (HasCompletedResults)
StatusHeadline = _loc.GetString(LastRunPassed ? "Test.Done.Passed" : "Test.Done.Failed");
else if (AllPreconditionsPassed)
StatusHeadline = _loc.GetString("Test.Status.Ready");
else
StatusHeadline = _loc.GetString("Test.Status.NotReady");
}
private void RefreshAuthRequired()
{
IsAuthRequired = Root.TestPanel.Tests
.Any(s => (s.Source?.RequiresAuth ?? false) && s.Phases.Any(p => p.IsEnabled));
}
// ── Event handlers ────────────────────────────────────────────────────────
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(MainViewModel.IsTestRunning)) return;
switch (e.PropertyName)
{
case nameof(MainViewModel.CurrentPump):
case nameof(MainViewModel.IsCanConnected):
Reevaluate();
break;
case nameof(MainViewModel.IsTestRunning):
OnPropertyChanged(nameof(IsTestRunning));
StartTestCommand.NotifyCanExecuteChanged();
AbortCommand.NotifyCanExecuteChanged();
ClearTestDataCommand.NotifyCanExecuteChanged();
OnTestRunningChanged();
break;
case nameof(MainViewModel.LastTestSuccess):
LastRunPassed = Root.LastTestSuccess;
RefreshStatusHeadline();
break;
}
}
private void OnTestRunningChanged()
{
if (Root.IsTestRunning)
{
if (CurrentState == TestFlowState.Preconditions)
CurrentState = TestFlowState.Running;
// Starting a fresh run — hide any stale completion state.
HasCompletedResults = false;
IsSnackbarVisible = false;
StopSnackbarTimer();
}
else
{
if (CurrentState == TestFlowState.Running)
CurrentState = TestFlowState.Done;
// Just finished.
LastRunPassed = Root.LastTestSuccess;
HasCompletedResults = true;
IsSnackbarVisible = true;
StartSnackbarTimer();
}
RefreshStatusHeadline();
}
private void OnAlarmsPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DashboardAlarmsViewModel.HasCritical))
Reevaluate();
}
private void OnAuthPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AuthGateViewModel.IsAuthenticated))
Reevaluate();
}
private void OnTestPanelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TestPanelViewModel.RemainingSeconds))
{
RefreshAuthRequired();
Reevaluate();
}
}
private void OnTestSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RebindPhaseEnabledWatchers();
RefreshAuthRequired();
Reevaluate();
}
/// <summary>
/// TestPanel does not raise a dedicated event for individual phase toggles, so
/// we subscribe directly to every <see cref="PhaseCardViewModel"/>. Called once
/// at construction and again whenever the section collection is replaced.
/// </summary>
private void RebindPhaseEnabledWatchers()
{
foreach (var section in Root.TestPanel.Tests)
{
foreach (var phase in section.Phases)
{
phase.PropertyChanged -= OnPhaseCardPropertyChanged;
phase.PropertyChanged += OnPhaseCardPropertyChanged;
}
}
}
private void OnPhaseCardPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(PhaseCardViewModel.IsEnabled))
{
RefreshAuthRequired();
Reevaluate();
}
}
private void OnLanguageChanged() => Reevaluate();
/// <summary>
/// Public hook for the view to request a precondition recompute after the
/// user toggles phases on/off (TestPanel does not raise a dedicated event
/// for enabled-phase changes).
/// </summary>
public void OnEnabledPhasesChanged()
{
RefreshAuthRequired();
Reevaluate();
}
// ── Snackbar timer ────────────────────────────────────────────────────────
private void StartSnackbarTimer()
{
StopSnackbarTimer();
_snackbarTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(SnackbarDurationMs)
};
_snackbarTimer.Tick += (_, _) =>
{
IsSnackbarVisible = false;
StopSnackbarTimer();
};
_snackbarTimer.Start();
}
private void StopSnackbarTimer()
{
if (_snackbarTimer == null) return;
_snackbarTimer.Stop();
_snackbarTimer = null;
}
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
@@ -93,6 +94,12 @@ namespace HC_APTBS.ViewModels
EnabledChanged?.Invoke(this);
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Inverts <see cref="IsEnabled"/>. Bound to the card's click gesture.</summary>
[RelayCommand]
private void ToggleEnabled() => IsEnabled = !IsEnabled;
// ── Public API ────────────────────────────────────────────────────────────
/// <summary>Resets execution state for a new test run.</summary>

View File

@@ -1,66 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// A single row in the Tests-page preconditions checklist.
/// <para>Labels and remediation hints are localised strings resolved by the parent
/// <see cref="TestPreconditionsViewModel"/> and refreshed whenever
/// <see cref="ILocalizationService.LanguageChanged"/> fires. The item itself only
/// carries the currently-resolved text plus a navigation hook so the view can offer
/// a "fix-it" link when the check fails.</para>
/// </summary>
public sealed partial class PreconditionItemViewModel : ObservableObject
{
/// <summary>Stable identifier used by the parent VM to look items up when refreshing.</summary>
public string Id { get; }
/// <summary>Localised human-readable check label (e.g. "Oil pump ON").</summary>
[ObservableProperty] private string _label = string.Empty;
/// <summary>True when the associated runtime check currently passes.</summary>
[ObservableProperty] private bool _isSatisfied;
/// <summary>When false, this check is advisory only and does not block Start.</summary>
[ObservableProperty] private bool _isRequired = true;
/// <summary>Localised fix-it hint shown when the check fails (e.g. "Go to Bench → start oil pump").</summary>
[ObservableProperty] private string _remediationText = string.Empty;
/// <summary>When non-null, the remediation button navigates to this page.</summary>
public AppPage? RemediationTargetPage { get; }
/// <summary>True when the remediation action should be offered (failing + has a target page).</summary>
public bool HasRemediation => !IsSatisfied && RemediationTargetPage.HasValue;
private readonly MainViewModel _root;
/// <param name="id">Stable identifier (used by the parent VM to patch state).</param>
/// <param name="root">Root view-model used to drive page navigation.</param>
/// <param name="remediationTargetPage">Destination page when the fix-it link is clicked, or null when no page applies.</param>
/// <param name="isRequired">When false this item is advisory only.</param>
public PreconditionItemViewModel(
string id,
MainViewModel root,
AppPage? remediationTargetPage = null,
bool isRequired = true)
{
Id = id;
_root = root;
RemediationTargetPage = remediationTargetPage;
_isRequired = isRequired;
}
/// <summary>Navigates the shell to the remediation target page.</summary>
[RelayCommand]
private void NavigateToFix()
{
if (RemediationTargetPage.HasValue)
_root.SelectedPage = RemediationTargetPage.Value;
}
partial void OnIsSatisfiedChanged(bool value) => OnPropertyChanged(nameof(HasRemediation));
}
}

View File

@@ -137,12 +137,16 @@ namespace HC_APTBS.ViewModels
/// <summary>Resets all slider values to zero and restores default min/max/step.</summary>
public void Reset()
{
// Min/Max/Step must be reset BEFORE Value: WPF's Slider coerces Value into
// [Minimum, Maximum]. If a previous test auto-expanded MeMin above 0 (or the
// operator widened it via the settings popup), assigning Value=0 first would
// be silently clamped to the stale Min, and the slider would stay stuck there.
_suppressSend = true;
try
{
FbkwValue = 0; FbkwMin = 0; FbkwMax = 100; FbkwStep = 10;
MeValue = 0; MeMin = 0; MeMax = 100; MeStep = 10;
PreInValue = 0; PreInMin = 0; PreInMax = 100; PreInStep = 10;
FbkwMin = 0; FbkwMax = 100; FbkwStep = 10; FbkwValue = 0;
MeMin = 0; MeMax = 100; MeStep = 10; MeValue = 0;
PreInMin = 0; PreInMax = 100; PreInStep = 10; PreInValue = 0;
}
finally
{

View File

@@ -52,9 +52,12 @@ namespace HC_APTBS.ViewModels
});
// Start loading the pump as soon as the identifier is read from ROM,
// before the full K-Line read completes.
_kwp.PumpIdentified += (pumpId) => App.Current.Dispatcher.Invoke(() =>
// before the full K-Line read completes. Use BeginInvoke so the K-Line
// background worker thread returns immediately and the read keeps progressing
// even if the UI thread is momentarily blocked (e.g. modal voltage warning).
_kwp.PumpIdentified += (pumpId) => App.Current.Dispatcher.BeginInvoke(() =>
{
_log.Info(LogId, $"PumpIdentified handler: '{pumpId}'");
KlinePumpId = pumpId;
AutoSelectPumpByKlineId(pumpId);
});
@@ -83,6 +86,13 @@ namespace HC_APTBS.ViewModels
/// </summary>
public event Action<PumpDefinition?>? PumpChanged;
/// <summary>
/// Raised at the end of a successful K-Line read with the matched pump ID and
/// the ECU serial number (may be empty if the ECU did not return one). Used by
/// the parent ViewModel to detect a physical pump swap when the model ID is unchanged.
/// </summary>
public event Action<string /*pumpId*/, string /*serial*/>? KlineReadCompleted;
/// <summary>Populates the pump ID list from the configuration database.</summary>
public void LoadPumpIds()
{
@@ -99,6 +109,7 @@ namespace HC_APTBS.ViewModels
private void LoadPump(string pumpId)
{
_log.Info(LogId, $"LoadPump: {pumpId}");
var pump = _config.LoadPump(pumpId);
if (pump == null)
{
@@ -217,12 +228,18 @@ namespace HC_APTBS.ViewModels
KlineConnectError = connectErr ?? string.Empty;
});
// Pump auto-selection now happens via the PumpIdentified event
// mid-read, so there is no need to call AutoSelectPumpByKlineId here.
// Pump auto-selection by pumpID/alias/substring already happened mid-read
// via the PumpIdentified event. If still unmatched, try ModelReference now
// that the full ECU text has been read (ModelRef arrives later than pumpID).
if (CurrentPump == null && !string.IsNullOrEmpty(modelRef))
App.Current.Dispatcher.Invoke(() => TryAutoSelectByModelRef(modelRef!));
// Attach K-Line info to the (now possibly auto-selected) pump.
if (CurrentPump != null)
CurrentPump.KlineInfo = info;
// Notify parent VM so it can detect physical pump swaps when the model ID is unchanged.
KlineReadCompleted?.Invoke(CurrentPump?.Id ?? string.Empty, serial ?? string.Empty);
}
finally
{
@@ -252,29 +269,75 @@ namespace HC_APTBS.ViewModels
/// <summary>
/// Tries to match a K-Line pump identifier to a pump in the database and auto-select it.
/// If the K-Line ID is directly in the pump list, select it. Otherwise, try to find
/// a pump whose ID is contained in the K-Line identifier string.
/// Resolution order:
/// <list type="number">
/// <item>Exact match — K-Line ID equals a canonical pump ID.</item>
/// <item>Alias match — K-Line ID is listed under a pump's <c>&lt;Aliases&gt;&lt;KlineId&gt;</c> entries.</item>
/// <item>Substring match — pump ID appears inside the K-Line ident string (legacy fallback for noisy ROM reads).</item>
/// </list>
/// ModelReference-based equivalence is handled separately after the full K-Line read completes
/// (see <see cref="TryAutoSelectByModelRef"/>).
/// </summary>
private void AutoSelectPumpByKlineId(string klinePumpId)
{
// Direct match — the K-Line ID is itself a pump ID in the database.
if (PumpIds.Contains(klinePumpId))
var trimmed = (klinePumpId ?? string.Empty).Trim();
if (trimmed.Length == 0)
{
App.Current.Dispatcher.Invoke(() => SelectedPumpId = klinePumpId);
_log.Warning(LogId, "AutoSelectPumpByKlineId: empty K-Line identifier.");
return;
}
// Substring match — the K-Line ident string may contain the pump ID.
// 1. Direct match — the K-Line ID is itself a pump ID in the database.
foreach (var id in PumpIds)
{
if (klinePumpId.Contains(id, StringComparison.OrdinalIgnoreCase))
if (string.Equals(id, trimmed, StringComparison.OrdinalIgnoreCase))
{
App.Current.Dispatcher.Invoke(() => SelectedPumpId = id);
_log.Info(LogId, $"Auto-selected '{id}' from K-Line id '{trimmed}' (exact match).");
SelectedPumpId = id;
return;
}
}
_log.Warning(LogId, $"K-Line pump ID '{klinePumpId}' not found in pump database.");
// 2. Alias match — the K-Line ID is registered as an alias under a canonical pump.
var aliased = _config.FindPumpIdByKlineAlias(trimmed);
if (!string.IsNullOrEmpty(aliased))
{
_log.Info(LogId, $"Auto-selected '{aliased}' from K-Line id '{trimmed}' (alias KlineId).");
SelectedPumpId = aliased;
return;
}
// 3. Substring match — the K-Line ident string may contain the pump ID.
foreach (var id in PumpIds)
{
if (trimmed.Contains(id, StringComparison.OrdinalIgnoreCase))
{
_log.Info(LogId, $"Auto-selected '{id}' from K-Line id '{trimmed}' (substring match).");
SelectedPumpId = id;
return;
}
}
_log.Warning(LogId,
$"K-Line pump ID '{trimmed}' not found in pump database ({PumpIds.Count} candidates).");
}
/// <summary>
/// Late-stage fallback used when the full K-Line read has completed and no pump was
/// matched by ID/alias/substring. Looks up the canonical pump ID by ModelReference alias
/// (e.g. <c>ME190297C150</c>). No-op if a pump is already selected.
/// </summary>
private void TryAutoSelectByModelRef(string modelRef)
{
if (CurrentPump != null) return;
var trimmed = (modelRef ?? string.Empty).Trim();
if (trimmed.Length == 0) return;
var canonical = _config.FindPumpIdByModelRef(trimmed);
if (string.IsNullOrEmpty(canonical)) return;
_log.Info(LogId, $"Auto-selected '{canonical}' from ModelRef '{trimmed}' (alias ModelRef).");
SelectedPumpId = canonical;
}
}
}

View File

@@ -12,14 +12,42 @@ namespace HC_APTBS.ViewModels
/// <summary>
/// Reusable ViewModel for a single real-time scrolling line chart.
/// Backed by LiveChartsCore with a fixed-width sample window.
///
/// <para>Two modes:</para>
/// <list type="bullet">
/// <item><b>Index-axis (default):</b> <see cref="LineSeries{TModel}"/> of doubles; X = array index;
/// axis is static. Samples shuffle through fixed X slots on each <see cref="AddValue"/>.</item>
/// <item><b>Smooth-scroll (opt-in):</b> <see cref="LineSeries{TModel}"/> of <see cref="ObservablePoint"/>
/// with X on a monotonic sample-index timeline. A host-driven per-frame hook calls
/// <see cref="UpdateViewport"/> to slide the X-axis window, producing true continuous
/// leftward motion independent of data cadence.</item>
/// </list>
/// </summary>
public sealed partial class SingleFlowChartViewModel : ObservableObject
{
private const int DefaultMaxSamples = 200;
private const int SmoothTrimMargin = 4;
private readonly ObservableCollection<double> _values = new();
private readonly bool _smoothScroll;
private readonly int _maxSamples;
// Index-axis mode storage.
private readonly ObservableCollection<double>? _values;
// Smooth-scroll mode storage + timeline state.
// The series holds N committed points at integer X positions plus one trailing
// "endpoint" whose X/Y interpolate each render frame from the previous committed
// point toward the pending sample. On each new sample the endpoint snaps to its
// target (becoming committed) and a new endpoint is appended at the same spot.
// Net effect: the line tip slides continuously instead of hopping +1 in X per sample.
private readonly ObservableCollection<ObservablePoint>? _points;
private long _nextCommitX;
private double _prevCommittedValue;
private double _pendingValue;
private DateTime _pendingArrivalUtc;
private int _sampleCount;
private ObservablePoint? _endpoint;
/// <summary>Chart title label.</summary>
[ObservableProperty] private string _title = string.Empty;
@@ -47,32 +75,69 @@ namespace HC_APTBS.ViewModels
/// <param name="title">Display title for the chart.</param>
/// <param name="lineColor">SKColor for the line series.</param>
/// <param name="maxSamples">Maximum number of samples before the oldest is dropped.</param>
public SingleFlowChartViewModel(string title, SKColor lineColor, int maxSamples = DefaultMaxSamples)
/// <param name="smoothScroll">
/// When true, store samples on a monotonic X timeline and expect the host to call
/// <see cref="UpdateViewport"/> each render frame to slide the visible window.
/// When false (default), use the legacy index-axis behavior.
/// </param>
public SingleFlowChartViewModel(string title, SKColor lineColor, int maxSamples = DefaultMaxSamples, bool smoothScroll = false)
{
_title = title;
_maxSamples = maxSamples;
_title = title;
_maxSamples = maxSamples;
_smoothScroll = smoothScroll;
Series = new ISeries[]
if (smoothScroll)
{
new LineSeries<double>
_points = new ObservableCollection<ObservablePoint>();
Series = new ISeries[]
{
Values = _values,
Fill = null,
GeometrySize = 0,
Stroke = new SolidColorPaint(lineColor, 2),
LineSmoothness = 0,
AnimationsSpeed = TimeSpan.Zero
}
};
new LineSeries<ObservablePoint>
{
Values = _points,
Fill = null,
GeometrySize = 0,
Stroke = new SolidColorPaint(lineColor, 2),
LineSmoothness = 0,
AnimationsSpeed = TimeSpan.Zero
}
};
XAxes = new Axis[]
{
new Axis
XAxes = new Axis[]
{
IsVisible = false,
AnimationsSpeed = TimeSpan.Zero
}
};
new Axis
{
IsVisible = false,
AnimationsSpeed = TimeSpan.Zero,
MinLimit = 0,
MaxLimit = maxSamples
}
};
}
else
{
_values = new ObservableCollection<double>();
Series = new ISeries[]
{
new LineSeries<double>
{
Values = _values,
Fill = null,
GeometrySize = 0,
Stroke = new SolidColorPaint(lineColor, 2),
LineSmoothness = 0,
AnimationsSpeed = TimeSpan.Zero
}
};
XAxes = new Axis[]
{
new Axis
{
IsVisible = false,
AnimationsSpeed = TimeSpan.Zero
}
};
}
YAxes = new Axis[]
{
@@ -91,10 +156,94 @@ namespace HC_APTBS.ViewModels
/// </summary>
public void AddValue(double value)
{
_values.Add(value);
CurrentValue = value;
if (_values.Count > _maxSamples)
_values.RemoveAt(0);
if (_smoothScroll)
{
_sampleCount++;
if (_sampleCount == 1)
{
// First sample — commit at X=0 and anchor the viewport so the point sits
// at the right edge. No endpoint yet; we need two samples before we can
// interpolate between previous and pending.
_points!.Add(new ObservablePoint(0, value));
_nextCommitX = 1;
_prevCommittedValue = value;
_pendingValue = value;
_pendingArrivalUtc = DateTime.UtcNow;
XAxes[0].MaxLimit = 0;
XAxes[0].MinLimit = -_maxSamples;
}
else if (_sampleCount == 2)
{
// Second sample — create the sliding endpoint at the previous committed
// point. UpdateViewport will interpolate it toward the pending target.
_pendingValue = value;
_pendingArrivalUtc = DateTime.UtcNow;
_endpoint = new ObservablePoint(_nextCommitX - 1, _prevCommittedValue);
_points!.Add(_endpoint);
}
else
{
// Third+ sample — finalize the existing endpoint at its target (making
// it a committed point), advance the commit index, then append a fresh
// endpoint at the just-finalized position. Because the endpoint is
// already at X = _nextCommitX after the last render frame with
// fraction≈1, the viewport continues from the same position without
// a visible hop.
_endpoint!.X = _nextCommitX;
_endpoint.Y = _pendingValue;
_prevCommittedValue = _pendingValue;
_nextCommitX++;
_pendingValue = value;
_pendingArrivalUtc = DateTime.UtcNow;
_endpoint = new ObservablePoint(_nextCommitX - 1, _prevCommittedValue);
_points!.Add(_endpoint);
// Keep a small margin past the visible window so the leftmost point
// doesn't pop as the viewport advances past the trim boundary.
while (_points.Count > _maxSamples + SmoothTrimMargin)
_points.RemoveAt(0);
}
}
else
{
_values!.Add(value);
if (_values.Count > _maxSamples)
_values.RemoveAt(0);
}
}
/// <summary>
/// Smooth-scroll mode only: slides the X-axis viewport so the rightmost visible edge
/// drifts continuously past the most recent sample, producing frame-rate smooth motion
/// independent of data cadence. Host calls this once per render frame (e.g. from
/// <c>CompositionTarget.Rendering</c>).
/// </summary>
/// <param name="nowUtc">Current time; pass <see cref="DateTime.UtcNow"/>.</param>
/// <param name="nominalPeriodMs">
/// Expected inter-sample period in milliseconds (e.g. <c>1000/Hz</c>). Used to interpolate
/// fractional progress between samples.
/// </param>
public void UpdateViewport(DateTime nowUtc, double nominalPeriodMs)
{
if (!_smoothScroll || _endpoint == null || nominalPeriodMs <= 0)
return;
double fraction = (nowUtc - _pendingArrivalUtc).TotalMilliseconds / nominalPeriodMs;
if (fraction < 0) fraction = 0;
else if (fraction > 1) fraction = 1;
double slideX = (_nextCommitX - 1) + fraction;
double slideY = _prevCommittedValue + (_pendingValue - _prevCommittedValue) * fraction;
_endpoint.X = slideX;
_endpoint.Y = slideY;
XAxes[0].MaxLimit = slideX;
XAxes[0].MinLimit = slideX - _maxSamples;
}
/// <summary>
@@ -116,11 +265,25 @@ namespace HC_APTBS.ViewModels
}
/// <summary>
/// Clears all sample data and tolerance bands.
/// Clears all sample data and tolerance bands. Resets the smooth-scroll timeline.
/// </summary>
public void Clear()
{
_values.Clear();
if (_smoothScroll)
{
_points!.Clear();
_nextCommitX = 0;
_prevCommittedValue = 0;
_pendingValue = 0;
_sampleCount = 0;
_endpoint = null;
XAxes[0].MinLimit = 0;
XAxes[0].MaxLimit = _maxSamples;
}
else
{
_values!.Clear();
}
Sections = Array.Empty<RectangularSection>();
}
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -157,7 +159,10 @@ namespace HC_APTBS.ViewModels
}
/// <summary>
/// Marks a phase as completed with the given pass/fail result.
/// Marks a phase as completed with the given pass/fail result. Also locks the
/// active phase's indicators to the pass/fail colour before the active-phase
/// reference is cleared, so the bar does not flash back to accent from a final
/// in-range oscillation sample.
/// </summary>
/// <param name="phaseName">Name of the completed phase.</param>
/// <param name="passed">True if the phase passed all criteria.</param>
@@ -186,12 +191,16 @@ namespace HC_APTBS.ViewModels
}
if (_activePhaseCard?.Name == phaseName)
{
MarkActivePhaseCompleted(passed);
_activePhaseCard = null;
}
}
/// <summary>
/// Updates the live measurement value on the graphic indicator for the currently
/// active phase that has a matching receive parameter.
/// active phase that has a matching receive parameter. Called every refresh tick
/// so the bar moves continuously through conditioning and measurement.
/// </summary>
/// <param name="paramName">CAN parameter name (e.g. "QDelivery").</param>
/// <param name="value">Current measured value.</param>
@@ -209,6 +218,45 @@ namespace HC_APTBS.ViewModels
}
}
/// <summary>
/// Applies a runtime tolerance/expected-value update (e.g. after DFI auto-adjust)
/// to the matching indicator on the active phase. Does not touch
/// <see cref="GraphicIndicatorViewModel.CurrentValue"/> — live values flow
/// through <see cref="UpdateLiveIndicator"/>.
/// </summary>
public void ApplyToleranceUpdate(string paramName, double expected, double tolerance)
{
if (_activePhaseCard == null) return;
foreach (var indicator in _activePhaseCard.ResultIndicators)
{
if (indicator.ParameterName == paramName)
{
indicator.ApplyTolerance(expected, tolerance);
return;
}
}
}
/// <summary>
/// Live-indicator list for the currently executing phase. Empty when no phase
/// is active. Consumers should iterate this once per refresh tick to push
/// current readings (see <see cref="MainViewModel.OnRefreshTick"/>).
/// </summary>
public IReadOnlyList<GraphicIndicatorViewModel> ActivePhaseIndicators
=> _activePhaseCard?.ResultIndicators as IReadOnlyList<GraphicIndicatorViewModel>
?? Array.Empty<GraphicIndicatorViewModel>();
private void MarkActivePhaseCompleted(bool passed)
{
if (_activePhaseCard == null) return;
foreach (var indicator in _activePhaseCard.ResultIndicators)
{
indicator.PhasePassed = passed;
indicator.IsPhaseCompleted = true;
}
}
/// <summary>
/// Applies a phase-timer tick from <see cref="IBenchService.PhaseTimerTick"/>.
/// Updates the sub-section label, remaining/total seconds and computed progress.

View File

@@ -1,280 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// Preconditions checklist for the Tests page "Preconditions" wizard step.
///
/// <para>Aggregates the seven safety/readiness checks specified in
/// <c>docs/ui-structure.md</c> §4b. Items auto-refresh whenever the underlying
/// <see cref="MainViewModel"/> properties change; <see cref="StartTestCommand"/>
/// stays disabled until every required check passes.</para>
///
/// <para>Subscriptions are established in <see cref="Activate"/> and released in
/// <see cref="Deactivate"/>; the parent view-model calls these as the wizard state
/// transitions into/out of Preconditions so we do not do work during Plan/Running/Done.</para>
/// </summary>
public sealed partial class TestPreconditionsViewModel : ObservableObject
{
// ── Stable item identifiers ───────────────────────────────────────────────
private const string IdPump = "pump";
private const string IdCan = "can";
private const string IdKLine = "kline";
private const string IdRpmZero = "rpmZero";
private const string IdOilPump = "oilPump";
private const string IdNoAlarms = "noAlarms";
private const string IdAuth = "auth";
// ── Resource keys ─────────────────────────────────────────────────────────
private const string KeyLabelPump = "Test.Precheck.PumpSelected";
private const string KeyLabelCan = "Test.Precheck.CanLive";
private const string KeyLabelKLine = "Test.Precheck.KLineOpen";
private const string KeyLabelRpmZero = "Test.Precheck.RpmZero";
private const string KeyLabelOilPump = "Test.Precheck.OilPumpOn";
private const string KeyLabelNoAlarms = "Test.Precheck.NoCriticalAlarms";
private const string KeyLabelAuth = "Test.Precheck.UserAuth";
private const string KeyRemPump = "Test.Precheck.Remediation.SelectPump";
private const string KeyRemCan = "Test.Precheck.Remediation.CheckCan";
private const string KeyRemKLine = "Test.Precheck.Remediation.OpenKLine";
private const string KeyRemRpmZero = "Test.Precheck.Remediation.StopBench";
private const string KeyRemOilPump = "Test.Precheck.Remediation.StartOilPump";
private const string KeyRemNoAlarms = "Test.Precheck.Remediation.ClearAlarms";
private const string KeyRemAuth = "Test.Precheck.Remediation.Authenticate";
private readonly MainViewModel _root;
private readonly ILocalizationService _loc;
private readonly TestPanelViewModel _testPanel;
private bool _subscribed;
/// <summary>Rows rendered by the checklist view, in display order.</summary>
public ObservableCollection<PreconditionItemViewModel> Items { get; } = new();
/// <summary>Gate used to authenticate the operator when a required test has <see cref="TestDefinition.RequiresAuth"/>.</summary>
public AuthGateViewModel TestAuth { get; }
/// <summary>True when the currently-enabled tests include at least one requiring authentication.</summary>
[ObservableProperty] private bool _isAuthRequired;
/// <summary>True when every required check passes — gates <see cref="StartTestCommand"/>.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartTestCommand))]
private bool _allPassed;
/// <param name="root">Root VM — source of all live bench/ECU state.</param>
/// <param name="loc">Localisation service for label refresh.</param>
/// <param name="testPanel">Panel VM — used to discover which tests are enabled and whether any require auth.</param>
/// <param name="testAuth">Auth gate scoped to the Tests page.</param>
public TestPreconditionsViewModel(
MainViewModel root,
ILocalizationService loc,
TestPanelViewModel testPanel,
AuthGateViewModel testAuth)
{
_root = root;
_loc = loc;
_testPanel = testPanel;
TestAuth = testAuth;
BuildItems();
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
/// <summary>
/// Called by the parent when the wizard enters the Preconditions state.
/// Subscribes to all live-state sources and evaluates once.
/// </summary>
public void Activate()
{
if (_subscribed) return;
_root.PropertyChanged += OnRootPropertyChanged;
_root.DashboardAlarms.PropertyChanged += OnAlarmsPropertyChanged;
TestAuth.PropertyChanged += OnAuthPropertyChanged;
_loc.LanguageChanged += OnLanguageChanged;
_subscribed = true;
RefreshAuthRequired();
RebuildAuthItemVisibility();
Reevaluate();
}
/// <summary>Called by the parent when the wizard leaves the Preconditions state.</summary>
public void Deactivate()
{
if (!_subscribed) return;
_root.PropertyChanged -= OnRootPropertyChanged;
_root.DashboardAlarms.PropertyChanged -= OnAlarmsPropertyChanged;
TestAuth.PropertyChanged -= OnAuthPropertyChanged;
_loc.LanguageChanged -= OnLanguageChanged;
_subscribed = false;
}
// ── Build ─────────────────────────────────────────────────────────────────
private void BuildItems()
{
Items.Clear();
Items.Add(new PreconditionItemViewModel(IdPump, _root, AppPage.Pump));
Items.Add(new PreconditionItemViewModel(IdCan, _root, AppPage.Dashboard));
Items.Add(new PreconditionItemViewModel(IdKLine, _root, AppPage.Pump));
Items.Add(new PreconditionItemViewModel(IdRpmZero, _root, AppPage.Bench));
Items.Add(new PreconditionItemViewModel(IdOilPump, _root, AppPage.Bench));
Items.Add(new PreconditionItemViewModel(IdNoAlarms, _root, AppPage.Dashboard));
// Auth item added on-demand (see RebuildAuthItemVisibility).
RefreshLabels();
}
private void RebuildAuthItemVisibility()
{
var authItem = Items.FirstOrDefault(i => i.Id == IdAuth);
if (IsAuthRequired && authItem == null)
{
Items.Add(new PreconditionItemViewModel(IdAuth, _root, remediationTargetPage: null));
RefreshLabels();
}
else if (!IsAuthRequired && authItem != null)
{
Items.Remove(authItem);
}
}
// ── Evaluation ────────────────────────────────────────────────────────────
/// <summary>Recomputes every item's satisfied state and <see cref="AllPassed"/>.</summary>
public void Reevaluate()
{
foreach (var item in Items)
item.IsSatisfied = EvaluateItem(item.Id);
AllPassed = Items.All(i => !i.IsRequired || i.IsSatisfied);
}
private bool EvaluateItem(string id) => id switch
{
IdPump => _root.CurrentPump != null,
IdCan => _root.IsCanConnected,
IdKLine => _root.KLineState == KLineConnectionState.Connected,
IdRpmZero => _root.BenchRpm == 0,
IdOilPump => _root.IsOilPumpOn,
IdNoAlarms => !_root.DashboardAlarms.HasCritical,
IdAuth => TestAuth.IsAuthenticated,
_ => true,
};
private void RefreshAuthRequired()
{
IsAuthRequired = _testPanel.Tests
.Any(s => (s.Source?.RequiresAuth ?? false) && s.Phases.Any(p => p.IsEnabled));
}
// ── Labels ────────────────────────────────────────────────────────────────
private void RefreshLabels()
{
foreach (var item in Items)
{
item.Label = _loc.GetString(LabelKeyFor(item.Id));
item.RemediationText = _loc.GetString(RemediationKeyFor(item.Id));
}
}
private static string LabelKeyFor(string id) => id switch
{
IdPump => KeyLabelPump,
IdCan => KeyLabelCan,
IdKLine => KeyLabelKLine,
IdRpmZero => KeyLabelRpmZero,
IdOilPump => KeyLabelOilPump,
IdNoAlarms => KeyLabelNoAlarms,
IdAuth => KeyLabelAuth,
_ => id,
};
private static string RemediationKeyFor(string id) => id switch
{
IdPump => KeyRemPump,
IdCan => KeyRemCan,
IdKLine => KeyRemKLine,
IdRpmZero => KeyRemRpmZero,
IdOilPump => KeyRemOilPump,
IdNoAlarms => KeyRemNoAlarms,
IdAuth => KeyRemAuth,
_ => string.Empty,
};
// ── Event handlers ────────────────────────────────────────────────────────
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(MainViewModel.CurrentPump):
case nameof(MainViewModel.IsCanConnected):
case nameof(MainViewModel.KLineState):
case nameof(MainViewModel.BenchRpm):
case nameof(MainViewModel.IsOilPumpOn):
Reevaluate();
break;
}
}
private void OnAlarmsPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DashboardAlarmsViewModel.HasCritical))
Reevaluate();
}
private void OnAuthPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AuthGateViewModel.IsAuthenticated))
Reevaluate();
}
private void OnLanguageChanged() => RefreshLabels();
/// <summary>
/// Called by the parent VM whenever the test-panel enabled-phase selection changes,
/// so the auth item can be shown/hidden based on enabled tests' <see cref="TestDefinition.RequiresAuth"/>.
/// </summary>
public void OnEnabledPhasesChanged()
{
RefreshAuthRequired();
RebuildAuthItemVisibility();
Reevaluate();
}
partial void OnIsAuthRequiredChanged(bool value)
{
RebuildAuthItemVisibility();
Reevaluate();
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Delegates to <see cref="MainViewModel.StartTestCommand"/> when <see cref="AllPassed"/> is true.</summary>
[RelayCommand(CanExecute = nameof(CanStart))]
private void StartTest()
{
if (_root.StartTestCommand.CanExecute(null))
_root.StartTestCommand.Execute(null);
}
private bool CanStart() => AllPassed;
}
}

View File

@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
@@ -29,6 +30,9 @@ namespace HC_APTBS.ViewModels
/// <summary>Human-readable description of the test type.</summary>
[ObservableProperty] private string _description = string.Empty;
/// <summary>WPF-UI SymbolIcon name to show in the card header (Fluent Tests page).</summary>
[ObservableProperty] private string _iconSymbol = "Beaker24";
/// <summary>Conditioning time in seconds.</summary>
[ObservableProperty] private int _conditioningTimeSec;
@@ -81,6 +85,12 @@ namespace HC_APTBS.ViewModels
}
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Inverts <see cref="AllPhasesChecked"/>. Bound to the section card's click gesture.</summary>
[RelayCommand]
private void ToggleAllPhases() => AllPhasesChecked = !AllPhasesChecked;
// ── Cascade: child → parent ──────────────────────────────────────────────
/// <summary>
@@ -118,6 +128,7 @@ namespace HC_APTBS.ViewModels
{
TestName = test.Name,
Description = loc.GetString(MapDescriptionKey(test.Name)),
IconSymbol = MapIconSymbol(test.Name),
ConditioningTimeSec = test.ConditioningTimeSec,
MeasurementTimeSec = test.MeasurementTimeSec,
MeasurementsPerSecond = test.MeasurementsPerSecond,
@@ -177,5 +188,20 @@ namespace HC_APTBS.ViewModels
TestType.Pfp => "TestType.PreInjection",
_ => testName
};
/// <summary>
/// Maps a test type identifier to a WPF-UI SymbolIcon name used in the
/// Fluent Tests page card header.
/// </summary>
private static string MapIconSymbol(string testName) => testName switch
{
TestType.Wl => "Temperature24",
TestType.Dfi => "WrenchScrewdriver24",
TestType.F => "Drop24",
TestType.Svme => "Timeline24",
TestType.Up => "ArrowTrendingLines24",
TestType.Pfp => "Gauge24",
_ => "Beaker24"
};
}
}