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:
68
ViewModels/AuthGateViewModel.cs
Normal file
68
ViewModels/AuthGateViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
62
ViewModels/DashboardAlarmsViewModel.cs
Normal file
62
ViewModels/DashboardAlarmsViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
ViewModels/Dialogs/ConfirmDialogViewModel.cs
Normal file
51
ViewModels/Dialogs/ConfirmDialogViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
179
ViewModels/Dialogs/UserManageViewModel.cs
Normal file
179
ViewModels/Dialogs/UserManageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
189
ViewModels/DtcListViewModel.cs
Normal file
189
ViewModels/DtcListViewModel.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
95
ViewModels/InterlockBannerViewModel.cs
Normal file
95
ViewModels/InterlockBannerViewModel.cs
Normal 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 > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
});
|
||||
|
||||
66
ViewModels/Pages/BenchPageViewModel.cs
Normal file
66
ViewModels/Pages/BenchPageViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
ViewModels/Pages/DashboardPageViewModel.cs
Normal file
25
ViewModels/Pages/DashboardPageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
ViewModels/Pages/PumpPageViewModel.cs
Normal file
132
ViewModels/Pages/PumpPageViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
273
ViewModels/Pages/ResultsPageViewModel.cs
Normal file
273
ViewModels/Pages/ResultsPageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
233
ViewModels/Pages/TestsPageViewModel.cs
Normal file
233
ViewModels/Pages/TestsPageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
ViewModels/PreconditionItemViewModel.cs
Normal file
66
ViewModels/PreconditionItemViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
38
ViewModels/PressureTraceChartViewModel.cs
Normal file
38
ViewModels/PressureTraceChartViewModel.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
40
ViewModels/RelayBankViewModel.cs
Normal file
40
ViewModels/RelayBankViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
69
ViewModels/TemperatureControlViewModel.cs
Normal file
69
ViewModels/TemperatureControlViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
280
ViewModels/TestPreconditionsViewModel.cs
Normal file
280
ViewModels/TestPreconditionsViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user