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