feat: page-based navigation shell + Tests page wizard

Replace the monolithic MainWindow with a SelectedPage-driven shell
(Dashboard / Pump / Bench / Tests / Results / Settings). The Tests
page gets the Plan -> Preconditions -> Running -> Done wizard from
ui-structure.md \u00a74, backed by a 7-item precondition gate and
shared sub-views (PhaseCardView / TestSectionView / GraphicIndicatorView)
extracted from the now-deleted monolithic TestPanelView.

New VMs / views:
- Tests wizard: TestPreconditions, PhaseCard, GraphicIndicator,
  TestSection, TestPlan, TestRunning, TestDone
- Dashboard panels: DashboardConnection, DashboardReadings,
  DashboardAlarms, InterlockBanner, ResultHistory
- Pump / bench panels: PumpIdentificationPanel, PumpLiveData,
  UnlockPanel, BenchDriveControl, BenchReadings, RelayBank,
  TemperatureControl, DtcList, AuthGate
- Dialogs: generic ConfirmDialog, UserManageDialog, UserPromptDialog

Supporting changes:
- IsOilPumpOn exposed on MainViewModel for precondition evaluation
- RequiresAuth added to TestDefinition (XML round-trip)
- BipStatusDefinition + CompletedTestRun models
- ~35 new Test.* localization keys (en + es)
- Settings moved from modal dialog to full page
- Pause / Retry / Skip stubs in TestRunningView; full spec in
  docs/gap-test-running-controls.md for follow-up implementation
- docs/ui-structure.md captures the wizard design

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 13:11:34 +02:00
parent 37d099cdbd
commit 0280a2fad1
110 changed files with 8008 additions and 1115 deletions

View File

@@ -0,0 +1,68 @@
using System;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// ViewModel for the <c>AuthGateView</c> wrapper control that hides write-capable
/// content behind an operator authentication dialog.
///
/// <para>Used by the Pump page on the Adaptation and Unlock sub-sections per
/// <c>docs/ui-structure.md</c> §3.d/§3.e (write actions require authentication).</para>
/// </summary>
public sealed partial class AuthGateViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Initialises the gate; starts in the locked state.</summary>
public AuthGateViewModel(IConfigurationService config, ILocalizationService loc)
{
_config = config;
_loc = loc;
}
// ── State ─────────────────────────────────────────────────────────────────
/// <summary>True once the operator has successfully authenticated.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AuthenticateCommand))]
[NotifyCanExecuteChangedFor(nameof(LockCommand))]
private bool _isAuthenticated;
/// <summary>The currently authenticated username (empty when locked).</summary>
[ObservableProperty] private string _authenticatedUser = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Opens the UserCheck dialog; flips to authenticated on success.</summary>
[RelayCommand(CanExecute = nameof(CanAuthenticate))]
private void Authenticate()
{
var vm = new UserCheckViewModel(_config, _loc, AuthenticatedUser);
var dlg = new UserCheckDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (!vm.Accepted) return;
AuthenticatedUser = vm.AuthenticatedUser;
IsAuthenticated = true;
}
private bool CanAuthenticate() => !IsAuthenticated;
/// <summary>Re-locks the gate (e.g. after finishing a sensitive operation).</summary>
[RelayCommand(CanExecute = nameof(CanLock))]
private void Lock()
{
IsAuthenticated = false;
AuthenticatedUser = string.Empty;
}
private bool CanLock() => IsAuthenticated;
}
}

View File

@@ -70,10 +70,11 @@ namespace HC_APTBS.ViewModels
_config = configService;
_bench.RpmCommandSent += () =>
{
TargetRpm = _bench.LastTargetRpm;
CommandVoltage = _bench.LastCommandVoltage;
};
Application.Current.Dispatcher.Invoke(() =>
{
TargetRpm = _bench.LastTargetRpm;
CommandVoltage = _bench.LastCommandVoltage;
});
}
// ── Direction toggle ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// Dashboard-side alarm aggregator.
///
/// <para>Keeps an <see cref="ActiveAlarms"/> collection in sync with the
/// bench <c>Alarms</c> CAN bitmask, resolving each set bit against the
/// alarm definitions loaded from <c>alarms.xml</c>. The consumer (MainViewModel's
/// refresh tick) calls <see cref="Update"/> every refresh interval.</para>
/// </summary>
public sealed partial class DashboardAlarmsViewModel : ObservableObject
{
private readonly IReadOnlyList<Alarm> _definitions;
private int _lastMask = -1;
/// <summary>List of currently asserted alarms.</summary>
public ObservableCollection<Alarm> ActiveAlarms { get; } = new();
/// <summary>True when no alarms are active — used to show the "System OK" banner.</summary>
[ObservableProperty] private bool _isClear = true;
/// <summary>True when at least one active alarm is flagged critical.</summary>
[ObservableProperty] private bool _hasCritical;
public DashboardAlarmsViewModel(IReadOnlyList<Alarm> definitions)
{
_definitions = definitions;
}
/// <summary>
/// Rebuilds <see cref="ActiveAlarms"/> from the current bench alarm bitmask.
/// Cheap no-op when the mask has not changed since the last call.
/// Must be invoked on the UI thread.
/// </summary>
public void Update(int mask)
{
if (mask == _lastMask) return;
_lastMask = mask;
ActiveAlarms.Clear();
bool anyCritical = false;
foreach (var def in _definitions)
{
bool bitSet = (mask & (1 << def.Bit)) != 0;
def.IsActive = bitSet;
if (!bitSet) continue;
ActiveAlarms.Add(def);
if (def.IsCritical) anyCritical = true;
}
IsClear = ActiveAlarms.Count == 0;
HasCritical = anyCritical;
}
}
}

View File

@@ -0,0 +1,51 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// Generic Yes/No (or Confirm/Cancel) modal dialog view-model.
///
/// <para>Reusable across abort-test, skip-phase, delete-pump, and any future
/// binary decision prompt. Caller sets <see cref="Title"/>, <see cref="Message"/>,
/// <see cref="ConfirmText"/>, <see cref="CancelText"/> before showing the dialog,
/// then inspects <see cref="Accepted"/> after the dialog closes.</para>
/// </summary>
public sealed partial class ConfirmDialogViewModel : ObservableObject
{
/// <summary>Window title.</summary>
[ObservableProperty] private string _title = string.Empty;
/// <summary>Body message — may be multi-line.</summary>
[ObservableProperty] private string _message = string.Empty;
/// <summary>Text shown on the positive-action button (defaults to a localised "OK").</summary>
[ObservableProperty] private string _confirmText = "OK";
/// <summary>Text shown on the cancel button (defaults to a localised "Cancel").</summary>
[ObservableProperty] private string _cancelText = "Cancel";
/// <summary>True when the operator clicked <see cref="ConfirmText"/>; false on cancel/close.</summary>
public bool Accepted { get; private set; }
/// <summary>Raised when the dialog should close itself.</summary>
public event Action? RequestClose;
/// <summary>Accepts the prompt — sets <see cref="Accepted"/> to true and closes.</summary>
[RelayCommand]
private void Confirm()
{
Accepted = true;
RequestClose?.Invoke();
}
/// <summary>Cancels the prompt — leaves <see cref="Accepted"/> false and closes.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
}
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.ObjectModel;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the user management dialog.
/// Invokes incremental methods on <see cref="IConfigurationService"/>
/// (<c>AddUser</c>, <c>RemoveUser</c>, <c>ChangeUserPassword</c>) so that
/// hashes of untouched accounts are preserved. Each action persists immediately;
/// the dialog has no Accept/Cancel split — Close simply dismisses the window.
/// </summary>
public sealed partial class UserManageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Creates the ViewModel and populates <see cref="Users"/> from the service.</summary>
public UserManageViewModel(IConfigurationService config, ILocalizationService loc)
{
_config = config;
_loc = loc;
ReloadUsers();
}
// ── Bindable state ────────────────────────────────────────────────────
/// <summary>Usernames currently stored in the configuration.</summary>
public ObservableCollection<string> Users { get; } = new();
/// <summary>Currently selected username in the DataGrid, or null when nothing is selected.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RemoveCommand))]
[NotifyCanExecuteChangedFor(nameof(ChangePasswordCommand))]
private string? _selectedUser;
/// <summary>Raised to close the owning dialog window.</summary>
public event Action? RequestClose;
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Prompts for a username and password, then adds the user via the service.</summary>
[RelayCommand]
private void Add()
{
var prompt = new UserPromptDialog(
_loc.GetString("Dialog.UserManage.Prompt.AddTitle"),
usernameVisible: true)
{
Owner = Application.Current?.Windows.Count > 0
? GetActiveWindow()
: null
};
if (prompt.ShowDialog() != true)
return;
string username = prompt.EnteredUsername?.Trim() ?? string.Empty;
string password = prompt.EnteredPassword ?? string.Empty;
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
return;
}
if (username.IndexOfAny(new[] { ':', ',' }) >= 0)
{
ShowError("Dialog.UserManage.Error.InvalidChars", "Dialog.UserManage.Error.InvalidCharsTitle");
return;
}
if (!_config.AddUser(username, password))
{
ShowError("Dialog.UserManage.Error.Duplicate", "Dialog.UserManage.Error.DuplicateTitle");
return;
}
ReloadUsers();
SelectedUser = username;
}
/// <summary>Removes the selected user after confirmation, refusing when only one user remains.</summary>
[RelayCommand(CanExecute = nameof(HasSelection))]
private void Remove()
{
string user = SelectedUser!;
var confirm = MessageBox.Show(
string.Format(_loc.GetString("Dialog.UserManage.Confirm.Remove"), user),
_loc.GetString("Dialog.UserManage.Confirm.RemoveTitle"),
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
return;
if (!_config.RemoveUser(user))
{
ShowError("Dialog.UserManage.Error.LastUser", "Dialog.UserManage.Error.LastUserTitle");
return;
}
ReloadUsers();
}
/// <summary>Prompts for a new password for the selected user and applies it.</summary>
[RelayCommand(CanExecute = nameof(HasSelection))]
private void ChangePassword()
{
string user = SelectedUser!;
var prompt = new UserPromptDialog(
string.Format(_loc.GetString("Dialog.UserManage.Prompt.ChangeTitle"), user),
usernameVisible: false,
prefillUsername: user)
{
Owner = GetActiveWindow()
};
if (prompt.ShowDialog() != true)
return;
string newPassword = prompt.EnteredPassword ?? string.Empty;
if (string.IsNullOrEmpty(newPassword))
{
ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
return;
}
if (!_config.ChangeUserPassword(user, newPassword))
{
ShowError("Dialog.UserManage.Error.Empty", "Dialog.UserManage.Error.EmptyTitle");
return;
}
}
/// <summary>Closes the dialog.</summary>
[RelayCommand]
private void Close() => RequestClose?.Invoke();
// ── Helpers ───────────────────────────────────────────────────────────
private bool HasSelection() => !string.IsNullOrEmpty(SelectedUser);
/// <summary>Reloads the user list from the service, preserving selection when possible.</summary>
private void ReloadUsers()
{
string? previous = SelectedUser;
Users.Clear();
foreach (var name in _config.GetUsers())
Users.Add(name);
SelectedUser = (previous != null && Users.Contains(previous)) ? previous : null;
}
private void ShowError(string messageKey, string titleKey)
{
MessageBox.Show(
_loc.GetString(messageKey),
_loc.GetString(titleKey),
MessageBoxButton.OK,
MessageBoxImage.Stop);
}
/// <summary>Returns the topmost active window for use as a dialog owner, or null if none.</summary>
private static Window? GetActiveWindow()
{
foreach (Window w in Application.Current.Windows)
{
if (w.IsActive) return w;
}
return Application.Current.MainWindow;
}
}
}

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// ViewModel for the Pump page §3.b DTC list (Diagnostic Trouble Codes).
///
/// <para>Exposes a list of fault-code lines parsed from
/// <see cref="IKwpService.ReadFaultCodesAsync"/>, with read/clear commands.
/// Each line is surfaced as a structured <see cref="DtcEntry"/> so the UI
/// can render them as rows rather than a raw blob.</para>
/// </summary>
public sealed partial class DtcListViewModel : ObservableObject
{
private readonly IKwpService _kwp;
private readonly ILocalizationService _loc;
private readonly IAppLogger _log;
private const string LogId = "DtcListVM";
/// <summary>Initialises the ViewModel with the required services.</summary>
public DtcListViewModel(IKwpService kwp, ILocalizationService loc, IAppLogger log)
{
_kwp = kwp;
_loc = loc;
_log = log;
}
// ── State ─────────────────────────────────────────────────────────────────
/// <summary>Parsed fault-code entries, one per line returned from K-Line.</summary>
public ObservableCollection<DtcEntry> Codes { get; } = new();
/// <summary>True while a read or clear operation is in progress.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ReadCommand))]
[NotifyCanExecuteChangedFor(nameof(ClearCommand))]
private bool _isBusy;
/// <summary>True when the last read returned no fault codes.</summary>
[ObservableProperty] private bool _isClear;
/// <summary>Status text shown above the list (empty, error, or "last read at …").</summary>
[ObservableProperty] private string _statusText = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Reads the current DTCs from the ECU over K-Line.</summary>
[RelayCommand(CanExecute = nameof(CanOperate))]
private async Task ReadAsync()
{
var port = _kwp.DetectKLinePort();
if (string.IsNullOrEmpty(port))
{
StatusText = _loc.GetString("Error.KLineNotFound");
return;
}
IsBusy = true;
try
{
string raw = await _kwp.ReadFaultCodesAsync(port);
ApplyRawText(raw);
StatusText = string.Format(_loc.GetString("Dtc.LastRead"), DateTime.Now);
}
catch (Exception ex)
{
_log.Error(LogId, $"ReadAsync: {ex.Message}");
StatusText = ex.Message;
}
finally
{
IsBusy = false;
}
}
/// <summary>Clears all DTCs on the ECU and refreshes the list.</summary>
[RelayCommand(CanExecute = nameof(CanOperate))]
private async Task ClearAsync()
{
var port = _kwp.DetectKLinePort();
if (string.IsNullOrEmpty(port))
{
StatusText = _loc.GetString("Error.KLineNotFound");
return;
}
IsBusy = true;
try
{
string raw = await _kwp.ClearFaultCodesAsync(port);
ApplyRawText(raw);
StatusText = _loc.GetString("Dtc.Cleared");
}
catch (Exception ex)
{
_log.Error(LogId, $"ClearAsync: {ex.Message}");
StatusText = ex.Message;
}
finally
{
IsBusy = false;
}
}
private bool CanOperate() => !IsBusy;
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Populates <see cref="Codes"/> from the raw K-Line fault-code string.
/// Handles the special "No fault codes" response by setting <see cref="IsClear"/>.
/// </summary>
private void ApplyRawText(string raw)
{
Application.Current.Dispatcher.Invoke(() =>
{
Codes.Clear();
if (string.IsNullOrWhiteSpace(raw)
|| raw.Contains("No fault", StringComparison.OrdinalIgnoreCase))
{
IsClear = true;
return;
}
IsClear = false;
foreach (var line in raw.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
string trimmed = line.Trim();
if (trimmed.Length == 0) continue;
Codes.Add(DtcEntry.Parse(trimmed));
}
});
}
/// <summary>Clears any cached DTCs (used when the pump selection changes).</summary>
public void Reset()
{
Application.Current.Dispatcher.Invoke(() =>
{
Codes.Clear();
IsClear = false;
StatusText = string.Empty;
});
}
}
/// <summary>
/// A single diagnostic trouble code row. Split into <see cref="Code"/> and
/// <see cref="Description"/> when the raw line follows the usual "CODE — text"
/// layout, otherwise the raw text is surfaced in <see cref="Description"/>.
/// </summary>
public sealed class DtcEntry
{
/// <summary>DTC code identifier (e.g. "P1688").</summary>
public string Code { get; set; } = string.Empty;
/// <summary>Human-readable description text.</summary>
public string Description { get; set; } = string.Empty;
/// <summary>Parses one line of K-Line fault-code output into a structured entry.</summary>
public static DtcEntry Parse(string line)
{
// Common K-Line formats: "P1234 — Description", "P1234: Description",
// "P1234 Description", or just raw text without a leading code.
int split = -1;
foreach (char sep in new[] { '—', '-', ':', '\t' })
{
split = line.IndexOf(sep);
if (split > 0) break;
}
if (split <= 0 || split > 10)
return new DtcEntry { Description = line };
return new DtcEntry
{
Code = line[..split].Trim(),
Description = line[(split + 1)..].Trim()
};
}
}
}

View File

@@ -0,0 +1,95 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// ViewModel for the bench-page interlock banner.
/// Surfaces two soft safety warnings inline (dismissible), matching the
/// Bench page guideline in <c>docs/ui-structure.md</c> §2:
/// <list type="bullet">
/// <item>Oil pump off while RPM &gt; threshold.</item>
/// <item>RPM above configured <c>AppSettings.MaxRpm</c> safety limit.</item>
/// </list>
/// </summary>
public sealed partial class InterlockBannerViewModel : ObservableObject
{
private const double OilPumpRpmThreshold = 300.0;
private readonly IConfigurationService _config;
/// <summary>Text shown in the banner. Empty string hides the banner.</summary>
[ObservableProperty] private string _message = string.Empty;
/// <summary>True when the banner is currently shown.</summary>
[ObservableProperty] private bool _isVisible;
/// <summary>True when the banner is showing a critical warning (red background).</summary>
[ObservableProperty] private bool _isCritical;
private bool _dismissed;
private int _dismissedFor; // Bit flags: 1=oil, 2=rpm
/// <param name="configService">Configuration service (source of <c>MaxRpm</c>).</param>
public InterlockBannerViewModel(IConfigurationService configService)
{
_config = configService;
}
/// <summary>
/// Recomputes the banner message from live bench state.
/// Called from the page's refresh tick handler.
/// </summary>
/// <param name="benchRpm">Current bench motor RPM.</param>
/// <param name="isOilPumpOn">True when the oil pump relay is energised.</param>
public void Update(double benchRpm, bool isOilPumpOn)
{
int maxRpm = Math.Max(1, _config.Settings.MaxRpm);
bool oilWarn = !isOilPumpOn && benchRpm > OilPumpRpmThreshold;
bool rpmWarn = benchRpm > maxRpm;
int activeFlags = (oilWarn ? 1 : 0) | (rpmWarn ? 2 : 0);
// Reset dismissal when the condition actually clears.
if (activeFlags == 0)
{
_dismissed = false;
_dismissedFor = 0;
Message = string.Empty;
IsCritical = false;
IsVisible = false;
return;
}
// If the operator already dismissed this exact condition set, stay hidden.
if (_dismissed && _dismissedFor == activeFlags)
return;
_dismissed = false;
if (rpmWarn)
{
Message = $"RPM {benchRpm:F0} exceeds safety limit ({maxRpm}).";
IsCritical = true;
}
else
{
Message = $"Oil pump is OFF while bench is running at {benchRpm:F0} RPM.";
IsCritical = false;
}
IsVisible = true;
_dismissedFor = activeFlags;
}
/// <summary>Hides the banner until the underlying condition changes.</summary>
[RelayCommand]
private void Dismiss()
{
_dismissed = true;
IsVisible = false;
}
}
}

View File

@@ -10,10 +10,28 @@ using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.ViewModels.Pages;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels
{
/// <summary>Identifies the top-level navigation page shown in the shell.</summary>
public enum AppPage
{
/// <summary>Bench controls, flowmeter charts, encoder angles.</summary>
Bench = 0,
/// <summary>Pump manual control, DFI, status displays.</summary>
Pump = 1,
/// <summary>Test suite, live progress, results.</summary>
Tests = 2,
/// <summary>At-a-glance operator landing page: readings, connections, alarms, quick actions.</summary>
Dashboard = 3,
/// <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
}
/// <summary>
/// Root ViewModel for the application's main window.
///
@@ -55,6 +73,25 @@ namespace HC_APTBS.ViewModels
/// <summary>ViewModel for the non-modal unlock progress window.</summary>
private UnlockProgressViewModel? _unlockVm;
/// <summary>
/// Publicly observable accessor for the currently running (or last completed)
/// immobilizer unlock VM. Used by the Pump page's inline unlock panel to
/// display the same state that the floating dialog shows. Null while no
/// unlock has been started for the current pump.
/// </summary>
public UnlockProgressViewModel? CurrentUnlockVm
{
get => _unlockVm;
private set
{
if (!ReferenceEquals(_unlockVm, value))
{
_unlockVm = value;
OnPropertyChanged();
}
}
}
/// <summary>The non-modal unlock progress window, if open.</summary>
private UnlockProgressDialog? _unlockDlg;
@@ -96,6 +133,40 @@ namespace HC_APTBS.ViewModels
/// <summary>ViewModel for the second pump status display (Empf3 word).</summary>
public StatusDisplayViewModel StatusDisplay2 { get; } = new();
/// <summary>ViewModel for the Dashboard's active-alarm list.</summary>
public DashboardAlarmsViewModel DashboardAlarms { get; }
/// <summary>Diagnostic Trouble Code list for the Pump page §3.b sub-section.</summary>
public DtcListViewModel DtcList { get; }
/// <summary>Auth gate for the Pump page §3.d Adaptation sub-section.</summary>
public AuthGateViewModel AdaptationAuth { get; }
// ── Page ViewModels (thin façades over the child VMs above) ───────────────
/// <summary>Dashboard navigation page VM.</summary>
public DashboardPageViewModel DashboardPage { get; private set; } = null!;
/// <summary>Bench navigation page VM.</summary>
public BenchPageViewModel BenchPage { get; private set; } = null!;
/// <summary>Pump navigation page VM.</summary>
public PumpPageViewModel PumpPage { get; private set; } = null!;
/// <summary>Tests navigation page VM.</summary>
public TestsPageViewModel TestsPage { get; private set; } = null!;
/// <summary>Settings navigation page VM.</summary>
public SettingsPageViewModel SettingsPage { get; private set; } = null!;
/// <summary>Results navigation page VM (session-only test-run history).</summary>
public ResultsPageViewModel ResultsPage { get; private set; } = null!;
// ── Navigation state ──────────────────────────────────────────────────────
/// <summary>Currently selected top-level navigation page.</summary>
[ObservableProperty] private AppPage _selectedPage = AppPage.Dashboard;
// ── Constructor ───────────────────────────────────────────────────────────
/// <summary>
@@ -130,6 +201,20 @@ namespace HC_APTBS.ViewModels
PumpControl = new PumpControlViewModel(benchService);
BenchControl = new BenchControlViewModel(benchService, configService);
AngleDisplay = new AngleDisplayViewModel(configService);
DashboardAlarms = new DashboardAlarmsViewModel(configService.Settings.Alarms);
DtcList = new DtcListViewModel(kwpService, localizationService, logger);
AdaptationAuth = new AuthGateViewModel(configService, 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}.
DashboardPage = new DashboardPageViewModel(this);
BenchPage = new BenchPageViewModel(this, benchService, configService);
PumpPage = new PumpPageViewModel(this, DtcList, AdaptationAuth);
TestsPage = new TestsPageViewModel(this, configService, localizationService);
SettingsPage = new SettingsPageViewModel(configService, localizationService);
SettingsPage.SettingsSaved += OnSettingsSaved;
ResultsPage = new ResultsPageViewModel(this, pdfService, configService, localizationService, logger);
// React to pump changes from the identification child VM.
PumpIdentification.PumpChanged += OnPumpChanged;
@@ -163,7 +248,12 @@ namespace HC_APTBS.ViewModels
{
CurrentPhaseName = phase;
TestPanel.SetActivePhase(phase);
// Clear real-time plot traces at each new phase boundary.
FlowmeterChart.Delivery.Clear();
FlowmeterChart.Over.Clear();
});
_bench.PhaseTimerTick += (section, remaining, total) => App.Current.Dispatcher.Invoke(
() => TestPanel.ApplyPhaseTimerTick(section, remaining, total));
_bench.VerboseMessage += msg => App.Current.Dispatcher.Invoke(() =>
{
VerboseStatus = msg;
@@ -191,6 +281,10 @@ namespace HC_APTBS.ViewModels
{
VerboseStatus = string.Format(_loc.GetString("Error.EmergencyStop"), reason);
});
_bench.StatusReactionTriggered += (bit, reaction, desc) => App.Current.Dispatcher.Invoke(() =>
{
VerboseStatus = $"[STATUS] bit {bit} reaction={reaction}: {desc}";
});
// Angle display: lock angle and PSG zero from test phases
_bench.LockAngleFaseReady += () => App.Current.Dispatcher.Invoke(() =>
@@ -281,8 +375,8 @@ namespace HC_APTBS.ViewModels
if (pump.UnlockType == 0) return;
_unlockCts = new CancellationTokenSource();
_unlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
_unlockDlg = new UnlockProgressDialog(_unlockVm)
CurrentUnlockVm = new UnlockProgressViewModel(_unlock, pump.UnlockType, _unlockCts, _loc);
_unlockDlg = new UnlockProgressDialog(_unlockVm!)
{ Owner = Application.Current.MainWindow };
// Start unlock in background — ViewModel tracks via event subscriptions.
@@ -312,7 +406,7 @@ namespace HC_APTBS.ViewModels
if (_unlockVm != null)
{
_unlockVm.Dispose();
_unlockVm = null;
CurrentUnlockVm = null;
}
if (_unlockDlg != null)
@@ -394,6 +488,13 @@ namespace HC_APTBS.ViewModels
/// <summary>PSG encoder position value.</summary>
[ObservableProperty] private double _psgEncoderValue;
/// <summary>
/// True when the Oil Pump relay is currently energised. Mirrored on each refresh
/// tick from <c>_config.Bench.Relays[RelayNames.OilPump]</c> so the Tests page
/// preconditions checklist can bind to it without walking the relay dictionary.
/// </summary>
[ObservableProperty] private bool _isOilPumpOn;
// ── Pump live readings (from pump CAN parameters) ──────────────────────────
/// <summary>Pump RPM reported by the ECU over CAN.</summary>
@@ -489,6 +590,17 @@ namespace HC_APTBS.ViewModels
private bool CanStopTest() => IsTestRunning;
/// <summary>
/// Operator-initiated emergency stop from the Dashboard.
/// Zeros the motor, zeros pump parameters, and cancels any running test.
/// </summary>
[RelayCommand]
private void EmergencyStop()
{
_bench.RequestEmergencyStop("Operator pressed E-Stop on Dashboard");
_testCts?.Cancel();
}
// ── Commands: relay toggles ───────────────────────────────────────────────
/// <summary>Toggles the electronic relay (pump solenoid power).</summary>
@@ -534,7 +646,12 @@ namespace HC_APTBS.ViewModels
{
string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string path = _pdf.GenerateReport(
CurrentPump, reportVm.OperatorName, reportVm.SelectedClientName, desktop);
CurrentPump,
reportVm.OperatorName,
reportVm.SelectedClientName,
desktop,
clientInfo: reportVm.ClientInfo,
observations: reportVm.Observations);
_log.Info(LogId, $"Report saved: {path}");
IsTestSaved = true;
@@ -552,15 +669,6 @@ namespace HC_APTBS.ViewModels
private bool CanGenerateReport()
=> CurrentPump != null && !IsTestRunning && CurrentPump.Tests.Count > 0;
// ── Commands: language toggle ──────────────────────────────────────────────
/// <summary>Toggles the UI language between Spanish and English.</summary>
[RelayCommand]
private void ToggleLanguage()
{
_loc.SetLanguage(_loc.CurrentLanguage == "ESP" ? "ENG" : "ESP");
}
/// <summary>Refreshes all ViewModel-cached localised strings after a language change.</summary>
private void RefreshLocalisedStrings()
{
@@ -569,17 +677,13 @@ namespace HC_APTBS.ViewModels
: _loc.GetString("Status.Disconnected");
}
// ── Commands: settings ────────────────────────────────────────────────────
/// <summary>Opens the settings dialog for editing application configuration.</summary>
[RelayCommand]
private void OpenSettings()
/// <summary>
/// Reseeds settings-dependent runtime state after the operator saves on the Settings page.
/// Currently only the bench refresh-timer interval needs re-application.
/// </summary>
private void OnSettingsSaved()
{
var vm = new SettingsViewModel(_config, _loc);
var dlg = new SettingsDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (vm.Accepted && _refreshTimer != null)
if (_refreshTimer != null)
_refreshTimer.Interval = TimeSpan.FromMilliseconds(_config.Settings.RefreshBenchInterfaceMs);
}
@@ -658,6 +762,15 @@ namespace HC_APTBS.ViewModels
FlowmeterChart.AddSamples(QDelivery, QOver);
BenchControl.RefreshFromTick();
// Mirror the oil pump relay state for the Tests page preconditions checklist.
IsOilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var oilRelay) && oilRelay.State;
// Feed page-scoped Bench VMs (pressure trace + interlock banner).
BenchPage.RefreshFromTick();
// Refresh Dashboard's active-alarm list from the bench alarm bitmask.
DashboardAlarms.Update((int)_bench.ReadBenchParameter(BenchParameterNames.Alarms));
if (CurrentPump != null)
{
PumpRpm = _bench.ReadPumpParameter(PumpParameterNames.Rpm);
@@ -708,6 +821,7 @@ namespace HC_APTBS.ViewModels
LastTestSuccess = !interrupted && success;
VerboseStatus = interrupted ? _loc.GetString("Test.Stopped") : (success ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail"));
TestPanel.IsRunning = false;
TestPanel.ClearPhaseTimer();
_bench.StopPumpSender();
StartTestCommand.NotifyCanExecuteChanged();
StopTestCommand.NotifyCanExecuteChanged();
@@ -716,6 +830,13 @@ namespace HC_APTBS.ViewModels
// Populate results table from all completed tests.
if (!interrupted && CurrentPump != null)
ResultDisplay.LoadAllResults(CurrentPump.Tests);
// Capture a session-only history entry (Results page §5) — covers normal
// and interrupted completions. Snapshot is deep-cloned so later runs
// cannot mutate this entry's data.
if (CurrentPump != null)
ResultsPage.CaptureRun(CurrentPump, interrupted, success);
_log.Info(LogId,
$"Test finished — interrupted={interrupted}, success={success}");
});

View File

@@ -0,0 +1,66 @@
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the Bench navigation page.
///
/// <para>Groups the bench-related child ViewModels owned by
/// <see cref="MainViewModel"/> together with page-specific façades
/// (temperature control, relay bank, pressure trace, interlock banner)
/// that only the Bench page consumes.</para>
/// </summary>
public sealed class BenchPageViewModel : ObservableObject
{
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Manual bench controls (direction, RPM start/stop, oil pump, counter).</summary>
public BenchControlViewModel BenchControl => Root.BenchControl;
/// <summary>Real-time flowmeter charts (Q-Delivery, Q-Over).</summary>
public FlowmeterChartViewModel FlowmeterChart => Root.FlowmeterChart;
/// <summary>Encoder angle monitoring (PSG, INJ, Manual, Lock Angle).</summary>
public AngleDisplayViewModel AngleDisplay => Root.AngleDisplay;
/// <summary>Temperature PID setpoint + heater / cooler relay toggles.</summary>
public TemperatureControlViewModel TempControl { get; }
/// <summary>Auxiliary relay toggle bank (Electronic, Flasher, Pulse4Signal).</summary>
public RelayBankViewModel RelayBank { get; }
/// <summary>Real-time pressure traces (P1, P2).</summary>
public PressureTraceChartViewModel PressureTrace { get; } = new();
/// <summary>Soft safety interlock banner state (oil pump / RPM limit).</summary>
public InterlockBannerViewModel Interlock { get; }
/// <param name="root">Root view-model providing live bench readings and child VMs.</param>
/// <param name="benchService">Bench service for setpoint and relay control.</param>
/// <param name="configService">Configuration service for safety limits and defaults.</param>
public BenchPageViewModel(
MainViewModel root,
IBenchService benchService,
IConfigurationService configService)
{
Root = root;
TempControl = new TemperatureControlViewModel(benchService, configService);
RelayBank = new RelayBankViewModel(benchService);
Interlock = new InterlockBannerViewModel(configService);
}
/// <summary>
/// Called from <see cref="MainViewModel.OnRefreshTick"/> to feed the
/// page-scoped VMs (pressure trace, interlock banner) from the latest
/// bench readings. Keeping this here avoids adding page-specific logic
/// to the root ViewModel.
/// </summary>
public void RefreshFromTick()
{
PressureTrace.AddSamples(Root.Pressure, Root.Pressure2);
Interlock.Update(Root.BenchRpm, BenchControl.IsOilPumpOn);
}
}
}

View File

@@ -0,0 +1,25 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the Dashboard navigation page.
///
/// <para>Thin façade — holds a <see cref="Root"/> reference so the Dashboard XAML
/// can bind to MainViewModel-owned live readings, connection state, test summary,
/// alarms, and commands via <c>{Binding Root.X}</c>.</para>
/// </summary>
public sealed class DashboardPageViewModel : ObservableObject
{
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Active alarm aggregator bound to the Dashboard alarm list.</summary>
public DashboardAlarmsViewModel Alarms => Root.DashboardAlarms;
public DashboardPageViewModel(MainViewModel root)
{
Root = root;
}
}
}

View File

@@ -0,0 +1,132 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
using HC_APTBS.ViewModels.Dialogs;
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>Identifies the sub-section shown inside the Pump navigation page.</summary>
public enum PumpSubPage
{
/// <summary>§3.a — Pump selection and K-Line ECU read.</summary>
Identification = 0,
/// <summary>§3.b — Diagnostic Trouble Codes.</summary>
Dtcs = 1,
/// <summary>§3.c — Live pump CAN readings and status words.</summary>
LiveData = 2,
/// <summary>§3.d — DFI calibration and ME/FBKW/PreIn manual control (auth-gated).</summary>
Adaptation = 3,
/// <summary>§3.e — Ford VP44 immobilizer unlock (visible only when required).</summary>
Unlock = 4
}
/// <summary>
/// ViewModel for the Pump navigation page.
///
/// <para>Thin façade that groups the pump-related child ViewModels owned by
/// <see cref="MainViewModel"/> and adds sub-page navigation, banner flags,
/// and the Adaptation auth gate. Holds a <see cref="Root"/> reference so
/// page XAML can bind to MainViewModel-owned properties (PumpRpm, PumpTemp,
/// KLineState, …) via <c>{Binding Root.X}</c>.</para>
/// </summary>
public sealed partial class PumpPageViewModel : ObservableObject
{
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
// ── Child VM façades ──────────────────────────────────────────────────────
/// <summary>Pump selector and K-Line read (§3.a).</summary>
public PumpIdentificationViewModel Identification => Root.PumpIdentification;
/// <summary>Diagnostic Trouble Code list (§3.b).</summary>
public DtcListViewModel DtcList { get; }
/// <summary>Adaptation sub-section auth gate (§3.d).</summary>
public AuthGateViewModel AdaptationAuth { get; }
/// <summary>DFI management (§3.d).</summary>
public DfiManageViewModel DfiViewModel => Root.DfiViewModel;
/// <summary>Manual pump control sliders (§3.d).</summary>
public PumpControlViewModel PumpControl => Root.PumpControl;
/// <summary>First pump status display — Status word (§3.c).</summary>
public StatusDisplayViewModel StatusDisplay1 => Root.StatusDisplay1;
/// <summary>Second pump status display — Empf3 word (§3.c).</summary>
public StatusDisplayViewModel StatusDisplay2 => Root.StatusDisplay2;
/// <summary>Current immobilizer unlock VM (§3.e). Null when no unlock is in progress for this pump.</summary>
public UnlockProgressViewModel? UnlockVm => Root.CurrentUnlockVm;
// ── Navigation state ──────────────────────────────────────────────────────
/// <summary>Currently selected Pump sub-section.</summary>
[ObservableProperty] private PumpSubPage _selectedSubPage = PumpSubPage.Identification;
// ── Banner flags (derived from Root state) ────────────────────────────────
/// <summary>True when a pump has been loaded from the database.</summary>
[ObservableProperty] private bool _isPumpSelected;
/// <summary>True when the K-Line session is currently open.</summary>
[ObservableProperty] private bool _isKLineSessionOpen;
/// <summary>True when the K-Line session is in the failed state.</summary>
[ObservableProperty] private bool _isKLineSessionFailed;
/// <summary>True for pumps that require a Ford immobilizer unlock (Type 1 or 2).</summary>
[ObservableProperty] private bool _isUnlockApplicable;
/// <summary>Constructs the page VM and subscribes to relevant Root state changes.</summary>
public PumpPageViewModel(
MainViewModel root,
DtcListViewModel dtcList,
AuthGateViewModel adaptationAuth)
{
Root = root;
DtcList = dtcList;
AdaptationAuth = adaptationAuth;
// Initialise derived flags from the current Root state.
RefreshDerivedFlags();
// Keep the derived flags in sync with Root changes.
Root.PropertyChanged += OnRootPropertyChanged;
Root.PumpIdentification.PumpChanged += _ => RefreshDerivedFlags();
}
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(MainViewModel.KLineState):
IsKLineSessionOpen = Root.KLineState == KLineConnectionState.Connected;
IsKLineSessionFailed = Root.KLineState == KLineConnectionState.Failed;
break;
case nameof(MainViewModel.CurrentUnlockVm):
OnPropertyChanged(nameof(UnlockVm));
break;
}
}
private void RefreshDerivedFlags()
{
IsPumpSelected = Root.CurrentPump != null;
IsKLineSessionOpen = Root.KLineState == KLineConnectionState.Connected;
IsKLineSessionFailed = Root.KLineState == KLineConnectionState.Failed;
IsUnlockApplicable = Root.CurrentPump != null && Root.CurrentPump.UnlockType != 0;
OnPropertyChanged(nameof(UnlockVm));
// When the pump changes, re-lock the adaptation gate — a new operator
// may be handling a different pump.
if (AdaptationAuth.IsAuthenticated)
AdaptationAuth.LockCommand.Execute(null);
// Drop any stale DTCs from the previous pump.
DtcList.Reset();
}
}
}

View File

@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Xml.Linq;
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>
/// ViewModel for the Results navigation page (§5 in <c>docs/ui-structure.md</c>).
///
/// <para>Owns a session-only history of completed test runs, drives the embedded
/// <see cref="ResultDisplayViewModel"/> detail pane, hosts the operator
/// observations/notes field, and runs the PDF export flow against the selected
/// snapshot.</para>
/// </summary>
public sealed partial class ResultsPageViewModel : ObservableObject
{
// ── Services ──────────────────────────────────────────────────────────────
private readonly IPdfService _pdf;
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
private readonly IAppLogger _log;
private const string LogId = "ResultsPage";
/// <summary>Remembers the last authenticated user to pre-fill the next auth dialog.</summary>
private string _lastAuthenticatedUser = string.Empty;
// ── Root reference ────────────────────────────────────────────────────────
/// <summary>Root ViewModel — lets page XAML bind app-global state when needed.</summary>
public MainViewModel Root { get; }
// ── Child VMs ─────────────────────────────────────────────────────────────
/// <summary>Detail-pane ViewModel driving the embedded <c>ResultDisplayView</c>.</summary>
public ResultDisplayViewModel Detail { get; }
// ── Observable state ──────────────────────────────────────────────────────
/// <summary>Newest-first list of captured completions for this session.</summary>
public ObservableCollection<CompletedTestRun> History { get; } = new();
/// <summary>Currently selected history entry, or null when the list is empty.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ExportPdfCommand))]
[NotifyCanExecuteChangedFor(nameof(RemoveEntryCommand))]
private CompletedTestRun? _selectedRun;
/// <summary>True while the history list has no entries — drives the empty-state UI.</summary>
public bool IsHistoryEmpty => History.Count == 0;
/// <summary>True when at least one entry exists — drives the Clear Session button's IsEnabled.</summary>
public bool HasAnyEntries => History.Count > 0;
// ── Constructor ───────────────────────────────────────────────────────────
/// <summary>Initialises the Results page VM with the services it needs.</summary>
public ResultsPageViewModel(
MainViewModel root,
IPdfService pdfService,
IConfigurationService configService,
ILocalizationService localizationService,
IAppLogger logger)
{
Root = root;
_pdf = pdfService;
_config = configService;
_loc = localizationService;
_log = logger;
Detail = new ResultDisplayViewModel(localizationService);
History.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(IsHistoryEmpty));
OnPropertyChanged(nameof(HasAnyEntries));
ClearHistoryCommand.NotifyCanExecuteChanged();
};
}
// ── Capture ───────────────────────────────────────────────────────────────
/// <summary>
/// Records a completed test session. Called from
/// <see cref="MainViewModel"/>'s <c>OnTestFinished</c> hook on the UI thread.
/// Captures a deep-cloned snapshot so that subsequent runs do not mutate
/// prior entries in place.
/// </summary>
public void CaptureRun(PumpDefinition pump, bool interrupted, bool success)
{
try
{
var snapshot = CloneForHistory(pump);
var entry = new CompletedTestRun
{
CompletedAt = DateTime.Now,
PumpModel = pump.Model,
PumpSerial = pump.SerialNumber,
Interrupted = interrupted,
OverallPassed = !interrupted && success && EvaluatePassed(snapshot.Tests),
PumpSnapshot = snapshot,
};
History.Insert(0, entry);
SelectedRun = entry;
}
catch (Exception ex)
{
_log.Error(LogId, $"CaptureRun: {ex.Message}");
}
}
partial void OnSelectedRunChanged(CompletedTestRun? value)
{
if (value == null)
{
Detail.Clear();
return;
}
Detail.LoadAllResults(value.PumpSnapshot.Tests);
}
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Authenticates the operator, collects report details, then writes the PDF.</summary>
[RelayCommand(CanExecute = nameof(CanExport))]
private void ExportPdf()
{
if (SelectedRun == null) return;
// Step 1: Authenticate operator (same flow as Test page report export).
var authVm = new UserCheckViewModel(_config, _loc, _lastAuthenticatedUser);
var authDlg = new UserCheckDialog(authVm) { Owner = Application.Current.MainWindow };
authDlg.ShowDialog();
if (!authVm.Accepted) return;
_lastAuthenticatedUser = authVm.AuthenticatedUser;
// Step 2: Collect report details, pre-filling observations from the captured run.
var reportVm = new ReportViewModel(_config)
{
OperatorName = authVm.AuthenticatedUser,
Observations = SelectedRun.Observations,
};
var reportDlg = new ReportDialog(reportVm) { Owner = Application.Current.MainWindow };
reportDlg.ShowDialog();
if (!reportVm.Accepted) return;
SelectedRun.Observations = reportVm.Observations;
try
{
string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string path = _pdf.GenerateReport(
SelectedRun.PumpSnapshot,
reportVm.OperatorName,
reportVm.SelectedClientName,
desktop,
clientInfo: reportVm.ClientInfo,
observations: reportVm.Observations);
_log.Info(LogId, $"Report saved: {path}");
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
{ UseShellExecute = true });
}
catch (Exception ex)
{
_log.Error(LogId, $"ExportPdf: {ex.Message}");
MessageBox.Show(
string.Format(_loc.GetString("Error.ReportGeneration"), ex.Message),
_loc.GetString("Error.ReportTitle"),
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private bool CanExport() => SelectedRun != null;
/// <summary>Removes a single history entry by <see cref="CompletedTestRun.Id"/>.</summary>
[RelayCommand(CanExecute = nameof(CanRemoveEntry))]
private void RemoveEntry(Guid id)
{
var target = History.FirstOrDefault(e => e.Id == id);
if (target == null) return;
bool wasSelected = ReferenceEquals(target, SelectedRun);
History.Remove(target);
if (wasSelected) SelectedRun = History.FirstOrDefault();
}
private bool CanRemoveEntry(Guid id) => History.Any(e => e.Id == id);
/// <summary>Clears the whole session history after operator confirmation.</summary>
[RelayCommand(CanExecute = nameof(HasAnyEntries))]
private void ClearHistory()
{
var result = MessageBox.Show(
_loc.GetString("Results.ClearSessionConfirm"),
_loc.GetString("Results.ClearSessionButton"),
MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes) return;
History.Clear();
SelectedRun = null;
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Builds a minimal deep copy of <paramref name="pump"/> that carries the
/// fields consumed by <see cref="IPdfService"/> and the detail view, with
/// <see cref="TestDefinition"/> entries deep-cloned via XML round-trip so
/// results cannot be mutated by subsequent runs.
/// </summary>
private static PumpDefinition CloneForHistory(PumpDefinition pump)
{
var clone = new PumpDefinition
{
Id = pump.Id,
Model = pump.Model,
SerialNumber = pump.SerialNumber,
Injector = pump.Injector,
Tube = pump.Tube,
Valve = pump.Valve,
Tension = pump.Tension,
Info = pump.Info,
EcuText = pump.EcuText,
Chaveta = pump.Chaveta,
LockAngle = pump.LockAngle,
LockAngleResult = pump.LockAngleResult,
HasPreInjection = pump.HasPreInjection,
Is4Cylinder = pump.Is4Cylinder,
UnlockType = pump.UnlockType,
Rotation = pump.Rotation,
KwpVersion = pump.KwpVersion,
};
foreach (var kv in pump.KlineInfo)
clone.KlineInfo[kv.Key] = kv.Value;
foreach (var test in pump.Tests)
{
XElement xml = test.ToXml();
clone.Tests.Add(TestDefinition.FromXml(xml));
}
return clone;
}
private static bool EvaluatePassed(IReadOnlyList<TestDefinition> tests)
{
bool anyEvaluated = false;
foreach (var t in tests)
{
foreach (var p in t.Phases)
{
if (!p.Enabled || p.Receives == null) continue;
foreach (var tp in p.Receives)
{
if (tp.Result == null) continue;
anyEvaluated = true;
if (!tp.Result.Passed) return false;
}
}
}
return anyEvaluated;
}
}
}

View File

@@ -3,29 +3,30 @@ using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Windows;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
using HC_APTBS.ViewModels.Dialogs;
using HC_APTBS.Views.Dialogs;
namespace HC_APTBS.ViewModels.Dialogs
namespace HC_APTBS.ViewModels.Pages
{
/// <summary>
/// ViewModel for the application settings dialog.
/// ViewModel for the Settings navigation page.
/// Loads a local copy of every <see cref="AppSettings"/> property so that
/// Cancel discards all changes.
/// Discard reverts all pending changes without touching persisted state.
/// </summary>
public sealed partial class SettingsViewModel : ObservableObject
public sealed partial class SettingsPageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
// ── Dialog result ─────────────────────────────────────────────────────
/// <summary>True when the user clicked Accept.</summary>
public bool Accepted { get; private set; }
/// <summary>Raised to close the owning dialog window.</summary>
public event Action? RequestClose;
/// <summary>
/// Raised after <see cref="SaveCommand"/> successfully persists settings.
/// The shell subscribes to reseed timers or other settings-dependent state.
/// </summary>
public event Action? SettingsSaved;
// ── Collections ───────────────────────────────────────────────────────
@@ -91,68 +92,20 @@ namespace HC_APTBS.ViewModels.Dialogs
/// <param name="configService">Configuration service for loading/saving settings.</param>
/// <param name="localizationService">Localization service for language switching.</param>
public SettingsViewModel(IConfigurationService configService, ILocalizationService localizationService)
public SettingsPageViewModel(IConfigurationService configService, ILocalizationService localizationService)
{
_config = configService;
_loc = localizationService;
var s = configService.Settings;
// General
_selectedLanguage = s.Language;
_daysKeepLogs = s.DaysKeepLogs;
// Safety
_tempMax = s.TempMax;
_tempMin = s.TempMin;
_securityRpmLimit = s.SecurityRpmLimit;
_maxPressureBar = s.MaxPressureBar;
_toleranceUpExtension = s.ToleranceUpExtension;
_tolerancePfpExtension = s.TolerancePfpExtension;
_defaultIgnoreTin = s.DefaultIgnoreTin;
// PID
_pidP = s.PidP;
_pidI = s.PidI;
_pidD = s.PidD;
_pidLoopMs = s.PidLoopMs;
// Motor
_encoderResolution = s.EncoderResolution;
_voltageForMaxRpm = s.VoltageForMaxRpm;
_maxRpm = s.MaxRpm;
_rightRelayValue = s.RightRelayValue;
// Company
_companyName = s.CompanyName;
_companyInfo = s.CompanyInfo;
_reportLogoPath = s.ReportLogoPath;
// K-Line
_selectedKLinePort = s.KLinePort;
// Advanced
_refreshBenchInterfaceMs = s.RefreshBenchInterfaceMs;
_refreshWhileReadingMs = s.RefreshWhileReadingMs;
_refreshCanBusReadMs = s.RefreshCanBusReadMs;
_refreshPumpRequestMs = s.RefreshPumpRequestMs;
_refreshPumpParamsMs = s.RefreshPumpParamsMs;
_blinkIntervalMs = s.BlinkIntervalMs;
_flasherIntervalMs = s.FlasherIntervalMs;
// Deep-copy the RPM-voltage relation table
foreach (var r in s.Relations)
Relations.Add(new RpmVoltageRelation(r.Voltage, r.Rpm));
// Enumerate connected FTDI devices
LoadFromConfig();
EnumerateFtdiDevices();
}
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Copies all local values back to AppSettings, saves, and closes.</summary>
/// <summary>Copies all local values back to AppSettings, saves to disk, applies language if changed.</summary>
[RelayCommand]
private void Accept()
private void Save()
{
var s = _config.Settings;
@@ -204,16 +157,14 @@ namespace HC_APTBS.ViewModels.Dialogs
_config.SaveSettings();
Accepted = true;
RequestClose?.Invoke();
SettingsSaved?.Invoke();
}
/// <summary>Discards changes and closes.</summary>
/// <summary>Reverts all local fields to the currently persisted <see cref="AppSettings"/> values.</summary>
[RelayCommand]
private void Cancel()
private void Discard()
{
Accepted = false;
RequestClose?.Invoke();
LoadFromConfig();
}
/// <summary>Opens a file dialog to select a company logo image.</summary>
@@ -258,8 +209,92 @@ namespace HC_APTBS.ViewModels.Dialogs
Relations.Remove(relation);
}
/// <summary>
/// Opens the user management dialog after a successful admin authentication.
/// The auth dialog is re-prompted on every invocation — there is no session cache.
/// </summary>
[RelayCommand]
private void ManageUsers()
{
var owner = GetOwnerWindow();
// Step 1: admin authentication.
var authVm = new UserCheckViewModel(_config, _loc);
var authDlg = new UserCheckDialog(authVm) { Owner = owner };
authDlg.ShowDialog();
if (!authVm.Accepted) return;
// Step 2: management dialog.
var manageVm = new UserManageViewModel(_config, _loc);
var manageDlg = new UserManageDialog(manageVm) { Owner = owner };
manageDlg.ShowDialog();
}
/// <summary>Finds a plausible dialog owner (active window, else main window).</summary>
private static Window? GetOwnerWindow()
{
foreach (Window w in Application.Current.Windows)
{
if (w.IsActive) return w;
}
return Application.Current.MainWindow;
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>Copies every persisted setting into the local mirror fields.</summary>
private void LoadFromConfig()
{
var s = _config.Settings;
// General
SelectedLanguage = s.Language;
DaysKeepLogs = s.DaysKeepLogs;
// Safety
TempMax = s.TempMax;
TempMin = s.TempMin;
SecurityRpmLimit = s.SecurityRpmLimit;
MaxPressureBar = s.MaxPressureBar;
ToleranceUpExtension = s.ToleranceUpExtension;
TolerancePfpExtension = s.TolerancePfpExtension;
DefaultIgnoreTin = s.DefaultIgnoreTin;
// PID
PidP = s.PidP;
PidI = s.PidI;
PidD = s.PidD;
PidLoopMs = s.PidLoopMs;
// Motor
EncoderResolution = s.EncoderResolution;
VoltageForMaxRpm = s.VoltageForMaxRpm;
MaxRpm = s.MaxRpm;
RightRelayValue = s.RightRelayValue;
// Company
CompanyName = s.CompanyName;
CompanyInfo = s.CompanyInfo;
ReportLogoPath = s.ReportLogoPath;
// K-Line
SelectedKLinePort = s.KLinePort;
// Advanced
RefreshBenchInterfaceMs = s.RefreshBenchInterfaceMs;
RefreshWhileReadingMs = s.RefreshWhileReadingMs;
RefreshCanBusReadMs = s.RefreshCanBusReadMs;
RefreshPumpRequestMs = s.RefreshPumpRequestMs;
RefreshPumpParamsMs = s.RefreshPumpParamsMs;
BlinkIntervalMs = s.BlinkIntervalMs;
FlasherIntervalMs = s.FlasherIntervalMs;
// Deep-copy the RPM-voltage relation table
Relations.Clear();
foreach (var r in s.Relations)
Relations.Add(new RpmVoltageRelation(r.Voltage, r.Rpm));
}
/// <summary>
/// Populates <see cref="AvailablePorts"/> with serial numbers of connected
/// FTDI devices. Fails silently if the FTDI driver DLL is not present.

View File

@@ -0,0 +1,233 @@
using System.ComponentModel;
using System.Linq;
using System.Windows;
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.
///
/// <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>
/// </summary>
public sealed partial class TestsPageViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Root ViewModel — owns services, live readings, and global commands.</summary>
public MainViewModel Root { get; }
/// <summary>Test panel: sections, phase cards, live indicators.</summary>
public TestPanelViewModel TestPanel => Root.TestPanel;
/// <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>
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>
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);
CurrentStateVm = _planVm;
Root.PropertyChanged += OnRootPropertyChanged;
}
// ── State ─────────────────────────────────────────────────────────────────
/// <summary>Current wizard step.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(AbortCommand))]
[NotifyCanExecuteChangedFor(nameof(RunAgainCommand))]
[NotifyCanExecuteChangedFor(nameof(ViewFullResultsCommand))]
private TestFlowState _currentState = TestFlowState.Plan;
/// <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>Convenience flag for view styling — true while a test is actively running.</summary>
public bool IsRunningStep => CurrentState == TestFlowState.Running;
/// <summary>Convenience flag for view styling — true when the page is on the Done step.</summary>
public bool IsDoneStep => CurrentState == TestFlowState.Done;
partial void OnCurrentStateChanged(TestFlowState oldValue, TestFlowState newValue)
{
if (oldValue == TestFlowState.Preconditions && newValue != TestFlowState.Preconditions)
Preconditions.Deactivate();
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;
}
OnPropertyChanged(nameof(IsRunningStep));
OnPropertyChanged(nameof(IsDoneStep));
}
// ── 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"/>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanNext))]
private void Next()
{
if (CurrentState != TestFlowState.Plan) return;
if (!Root.TestPanel.Tests.Any(s => s.Phases.Any(p => p.IsEnabled))) return;
CurrentState = TestFlowState.Preconditions;
}
private bool CanNext() => CurrentState == TestFlowState.Plan;
/// <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>
[RelayCommand(CanExecute = nameof(CanAbort))]
private void Abort()
{
var vm = new ConfirmDialogViewModel
{
Title = _loc.GetString("Test.Abort.Title"),
Message = _loc.GetString("Test.Abort.Message"),
ConfirmText = _loc.GetString("Test.Abort.Confirm"),
CancelText = _loc.GetString("Test.Abort.Cancel"),
};
var dlg = new ConfirmDialog(vm) { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
if (!vm.Accepted) return;
if (Root.StopTestCommand.CanExecute(null))
Root.StopTestCommand.Execute(null);
}
private bool CanAbort() => CurrentState == TestFlowState.Running;
/// <summary>Resets the page for a fresh run without reloading the pump.</summary>
[RelayCommand(CanExecute = nameof(CanRunAgain))]
private void RunAgain()
{
Root.TestPanel.ResetResults();
Root.ResultDisplay.Clear();
CurrentState = TestFlowState.Plan;
}
private bool CanRunAgain() => CurrentState == TestFlowState.Done;
/// <summary>Jumps to the Results navigation page.</summary>
[RelayCommand(CanExecute = nameof(CanViewFullResults))]
private void ViewFullResults()
{
Root.SelectedPage = AppPage.Results;
}
private bool CanViewFullResults() => CurrentState == TestFlowState.Done;
// ── IsTestRunning → wizard state sync ─────────────────────────────────────
private void OnRootPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(MainViewModel.IsTestRunning)) return;
if (Root.IsTestRunning)
{
if (CurrentState == TestFlowState.Preconditions)
CurrentState = TestFlowState.Running;
}
else
{
if (CurrentState == TestFlowState.Running)
CurrentState = TestFlowState.Done;
}
}
}
}

View File

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

View File

@@ -0,0 +1,38 @@
using SkiaSharp;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// Container ViewModel holding two real-time pressure traces:
/// one for P1 (bench oil pressure) and one for P2 (analogue sensor 2).
/// Reuses <see cref="SingleFlowChartViewModel"/> — the chart primitive is
/// identical, only the series colours and titles differ.
/// </summary>
public sealed class PressureTraceChartViewModel
{
/// <summary>Chart for P1 (red line).</summary>
public SingleFlowChartViewModel P1 { get; }
= new("P1 (bar)", new SKColor(0xD6, 0x28, 0x28));
/// <summary>Chart for P2 (cyan line).</summary>
public SingleFlowChartViewModel P2 { get; }
= new("P2 (bar)", new SKColor(0x00, 0xB4, 0xD8));
/// <summary>
/// Appends a sample pair to both traces.
/// Must be called on the UI thread.
/// </summary>
public void AddSamples(double p1, double p2)
{
P1.AddValue(p1);
P2.AddValue(p2);
}
/// <summary>Clears both traces.</summary>
public void Clear()
{
P1.Clear();
P2.Clear();
}
}
}

View File

@@ -0,0 +1,40 @@
using CommunityToolkit.Mvvm.ComponentModel;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// ViewModel for a curated bank of auxiliary relay toggles on the Bench page.
/// Excludes relays driven by dedicated controls
/// (oil pump, direction, heater / cooler, counter).
/// </summary>
public sealed partial class RelayBankViewModel : ObservableObject
{
private readonly IBenchService _bench;
/// <summary>Solenoid power / pump keep-alive relay.</summary>
[ObservableProperty] private bool _isElectronicOn;
/// <summary>Panel flasher relay (status lamp blink).</summary>
[ObservableProperty] private bool _isFlasherOn;
/// <summary>4-pulse-per-revolution output signal relay.</summary>
[ObservableProperty] private bool _isPulse4SignalOn;
/// <param name="benchService">Bench service used to drive the relays.</param>
public RelayBankViewModel(IBenchService benchService)
{
_bench = benchService;
}
partial void OnIsElectronicOnChanged(bool value)
=> _bench.SetRelay(RelayNames.Electronic, value);
partial void OnIsFlasherOnChanged(bool value)
=> _bench.SetRelay(RelayNames.Flasher, value);
partial void OnIsPulse4SignalOnChanged(bool value)
=> _bench.SetRelay(RelayNames.Pulse4Signal, value);
}
}

View File

@@ -0,0 +1,69 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels
{
/// <summary>
/// ViewModel for the bench Temperature Control panel:
/// PID setpoint input and heater / deposit cooler / inlet cooler relay toggles.
/// </summary>
public sealed partial class TemperatureControlViewModel : ObservableObject
{
private readonly IBenchService _bench;
private readonly IConfigurationService _config;
/// <summary>Operator-entered PID setpoint in °C (text to allow invariant parsing).</summary>
[ObservableProperty] private string _setpointText = "40";
/// <summary>Tolerance ±°C applied when the setpoint is committed.</summary>
[ObservableProperty] private string _toleranceText = "2";
/// <summary>True when the deposit heater relay is energised.</summary>
[ObservableProperty] private bool _isHeaterOn;
/// <summary>True when the deposit cooler relay is energised.</summary>
[ObservableProperty] private bool _isDepositCoolerOn;
/// <summary>True when the T-in (inlet) cooler relay is energised.</summary>
[ObservableProperty] private bool _isTinCoolerOn;
/// <param name="benchService">Bench service for PID setpoint and relay control.</param>
/// <param name="configService">Configuration service — seeds setpoint from <see cref="AppSettings"/>.</param>
public TemperatureControlViewModel(IBenchService benchService, IConfigurationService configService)
{
_bench = benchService;
_config = configService;
// Seed setpoint from global temperature band midpoint, tolerance from its half-width.
int min = _config.Settings.TempMin;
int max = _config.Settings.TempMax;
int mid = (min + max) / 2;
int tol = (max - min) / 2;
SetpointText = mid.ToString(CultureInfo.InvariantCulture);
ToleranceText = tol.ToString(CultureInfo.InvariantCulture);
}
partial void OnIsHeaterOnChanged(bool value)
=> _bench.SetRelay(RelayNames.DepositHeater, value);
partial void OnIsDepositCoolerOnChanged(bool value)
=> _bench.SetRelay(RelayNames.DepositCooler, value);
partial void OnIsTinCoolerOnChanged(bool value)
=> _bench.SetRelay(RelayNames.TinCooler, value);
/// <summary>Applies the PID setpoint entered by the operator.</summary>
[RelayCommand]
private void ApplySetpoint()
{
if (!double.TryParse(SetpointText, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp))
return;
if (!double.TryParse(ToleranceText, NumberStyles.Float, CultureInfo.InvariantCulture, out double tol))
tol = 2;
_bench.SetTemperatureSetpoint(sp, tol);
}
}
}

View File

@@ -47,6 +47,23 @@ namespace HC_APTBS.ViewModels
/// <summary>Estimated remaining time for the entire test sequence (seconds).</summary>
[ObservableProperty] private int _remainingSeconds;
// ── Active phase countdown (driven by IBenchService.PhaseTimerTick) ───────
/// <summary>Name of the currently running phase (empty when idle).</summary>
[ObservableProperty] private string _currentPhaseName = string.Empty;
/// <summary>Sub-section of the current phase: "Conditioning", "Measuring", or empty.</summary>
[ObservableProperty] private string _sectionLabel = string.Empty;
/// <summary>Seconds remaining in the current sub-section countdown.</summary>
[ObservableProperty] private int _phaseRemainingSeconds;
/// <summary>Total seconds for the current sub-section (denominator for progress).</summary>
[ObservableProperty] private int _phaseTotalSeconds;
/// <summary>Progress through the current sub-section (0.0 → 1.0).</summary>
[ObservableProperty] private double _phaseProgress;
// ── Test sections ─────────────────────────────────────────────────────────
/// <summary>All test sections for the currently loaded pump.</summary>
@@ -114,7 +131,9 @@ namespace HC_APTBS.ViewModels
/// <param name="phaseName">Name of the phase that is now running.</param>
public void SetActivePhase(string phaseName)
{
StatusText = phaseName;
StatusText = phaseName;
CurrentPhaseName = phaseName;
ClearPhaseTimer();
_activePhaseCard = null;
foreach (var section in Tests)
@@ -190,6 +209,28 @@ namespace HC_APTBS.ViewModels
}
}
/// <summary>
/// Applies a phase-timer tick from <see cref="IBenchService.PhaseTimerTick"/>.
/// Updates the sub-section label, remaining/total seconds and computed progress.
/// Must be called on the UI thread.
/// </summary>
public void ApplyPhaseTimerTick(string section, int remaining, int total)
{
SectionLabel = section;
PhaseRemainingSeconds = remaining;
PhaseTotalSeconds = total;
PhaseProgress = total > 0 ? 1.0 - (double)remaining / total : 0.0;
}
/// <summary>Clears the active-phase countdown (call on phase change and test end).</summary>
public void ClearPhaseTimer()
{
SectionLabel = string.Empty;
PhaseRemainingSeconds = 0;
PhaseTotalSeconds = 0;
PhaseProgress = 0;
}
/// <summary>
/// Resets all phase execution states and graphic indicators for a fresh test run.
/// </summary>
@@ -197,6 +238,8 @@ namespace HC_APTBS.ViewModels
{
_activePhaseCard = null;
StatusText = string.Empty;
CurrentPhaseName = string.Empty;
ClearPhaseTimer();
foreach (var section in Tests)
{

View File

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