feat: add Ford VP44 unlock progress dialog, K-Line fast unlock, localization, safety dialogs, and settings

Unlock progress UI:
- UnlockProgressDialog with dark-themed progress ring, phase indicator, elapsed
  time, and cancel/close buttons (non-modal, draggable borderless window)
- UnlockProgressViewModel with event-driven progress tracking via IUnlockService
- Triggers on pump selection (manual or K-Line auto-detect), not test start

UnlockService rewrite:
- Persistent CAN senders that outlive the unlock sequence (StopSenders on pump change)
- Concurrent K-Line fast unlock: awaits session Connected, sends RAM timer shortcut
  ({02 88 02 03 A8 01 00}), verifies via CAN TestUnlock before skipping wait
- Fix Type 1 verification (Value == 0 means unlocked, was inverted)

K-Line fast unlock support:
- IKwpService.TryFastUnlockAsync / KwpService implementation

Additional features:
- ILocalizationService with ES/EN resource dictionaries and runtime switching
- Safety dialogs: VoltageWarning, OilPumpConfirm, RpmSafetyWarning
- SettingsDialog for app configuration
- BenchService enhancements, ConfigurationService improvements, PDF report updates
- All UI strings localized via DynamicResource

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:22:48 +02:00
parent c617854c09
commit 37d099cdbd
55 changed files with 3207 additions and 379 deletions

View File

@@ -20,6 +20,7 @@ namespace HC_APTBS.ViewModels.Dialogs
private readonly IKwpService _kwp;
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
// ── Constructor ───────────────────────────────────────────────────────────
@@ -27,10 +28,12 @@ namespace HC_APTBS.ViewModels.Dialogs
public KlineErrorsViewModel(
IKwpService kwpService,
IConfigurationService configService,
ILocalizationService loc,
string initialErrors = "")
{
_kwp = kwpService;
_config = configService;
_loc = loc;
ErrorText = initialErrors;
_kwp.ProgressChanged += OnProgress;
@@ -120,8 +123,8 @@ namespace HC_APTBS.ViewModels.Dialogs
if (!string.IsNullOrEmpty(port)) return port;
MessageBox.Show(
"K-Line device not found. Check that the FTDI adapter is connected.",
"K-Line Error", MessageBoxButton.OK, MessageBoxImage.Warning);
_loc.GetString("Error.KLineNotFound"),
_loc.GetString("Error.KLineTitle"), MessageBoxButton.OK, MessageBoxImage.Warning);
return null;
}

View File

@@ -0,0 +1,51 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the oil pump confirmation dialog shown before activating
/// the oil pump relay. The operator must confirm that oil level and
/// connections have been checked.
/// Equivalent to the old <c>WAcceptOilTurnOn</c> dialog.
/// </summary>
public sealed partial class OilPumpConfirmViewModel : ObservableObject
{
// ── Dialog result ─────────────────────────────────────────────────────────
/// <summary>True if the operator confirmed and accepted.</summary>
public bool Accepted { get; private set; }
// ── Checkbox ──────────────────────────────────────────────────────────────
/// <summary>True when the "I have checked for leaks" checkbox is ticked.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AcceptCommand))]
private bool _leaksChecked;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Confirms the oil pump activation and closes the dialog.</summary>
[RelayCommand(CanExecute = nameof(CanAccept))]
private void Accept()
{
Accepted = true;
RequestClose?.Invoke();
}
private bool CanAccept() => LeaksChecked;
/// <summary>Cancels the oil pump activation and closes the dialog.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event System.Action? RequestClose;
}
}

View File

@@ -0,0 +1,72 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// Result of the RPM safety warning dialog.
/// </summary>
public enum RpmSafetyResult
{
/// <summary>User cancelled — do not start the motor.</summary>
Cancel,
/// <summary>Turn on oil pump first, then start the motor.</summary>
ProceedWithOil,
/// <summary>Proceed without oil pump (operator acknowledges risk).</summary>
ProceedWithoutOil
}
/// <summary>
/// ViewModel for the RPM safety warning dialog shown when the operator
/// starts the bench motor while the oil pump is OFF.
/// Equivalent to the old <c>WCareOnRpmOn</c> dialog.
/// </summary>
public sealed partial class RpmSafetyWarningViewModel : ObservableObject
{
// ── Dialog result ─────────────────────────────────────────────────────────
/// <summary>The operator's chosen action.</summary>
public RpmSafetyResult Result { get; private set; } = RpmSafetyResult.Cancel;
// ── Radio button selection ────────────────────────────────────────────────
/// <summary>True when the "turn on oil pump and proceed" option is selected.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AcceptCommand))]
private bool _isOilAndProceedSelected;
/// <summary>True when the "proceed without oil" option is selected.</summary>
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AcceptCommand))]
private bool _isProceedWithoutOilSelected;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Accepts the selected option and closes the dialog.</summary>
[RelayCommand(CanExecute = nameof(CanAccept))]
private void Accept()
{
Result = IsOilAndProceedSelected
? RpmSafetyResult.ProceedWithOil
: RpmSafetyResult.ProceedWithoutOil;
RequestClose?.Invoke();
}
private bool CanAccept() => IsOilAndProceedSelected || IsProceedWithoutOilSelected;
/// <summary>Cancels and closes the dialog.</summary>
[RelayCommand]
private void Cancel()
{
Result = RpmSafetyResult.Cancel;
RequestClose?.Invoke();
}
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event System.Action? RequestClose;
}
}

View File

@@ -0,0 +1,290 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Models;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the application settings dialog.
/// Loads a local copy of every <see cref="AppSettings"/> property so that
/// Cancel discards all changes.
/// </summary>
public sealed partial class SettingsViewModel : 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;
// ── Collections ───────────────────────────────────────────────────────
/// <summary>Available language codes for the language dropdown.</summary>
public ObservableCollection<string> AvailableLanguages { get; } = new() { "ESP", "ENG" };
/// <summary>RPM-voltage lookup table, editable via DataGrid.</summary>
public ObservableCollection<RpmVoltageRelation> Relations { get; } = new();
/// <summary>Available FTDI device serial numbers for K-Line port selection.</summary>
public ObservableCollection<string> AvailablePorts { get; } = new();
// ── General ───────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedLanguage = "ESP";
[ObservableProperty] private int _daysKeepLogs = 7;
// ── Safety ────────────────────────────────────────────────────────────
[ObservableProperty] private int _tempMax = 45;
[ObservableProperty] private int _tempMin = 35;
[ObservableProperty] private int _securityRpmLimit = 2500;
[ObservableProperty] private int _maxPressureBar = 26;
[ObservableProperty] private double _toleranceUpExtension = 0.08;
[ObservableProperty] private double _tolerancePfpExtension = 0.1;
[ObservableProperty] private bool _defaultIgnoreTin = true;
// ── PID ───────────────────────────────────────────────────────────────
[ObservableProperty] private double _pidP = 0.1;
[ObservableProperty] private double _pidI = 0.1;
[ObservableProperty] private double _pidD = 0.04;
[ObservableProperty] private int _pidLoopMs = 250;
// ── Motor ─────────────────────────────────────────────────────────────
[ObservableProperty] private int _encoderResolution = 4096;
[ObservableProperty] private double _voltageForMaxRpm = 10;
[ObservableProperty] private int _maxRpm = 2500;
[ObservableProperty] private bool _rightRelayValue = true;
// ── Company ───────────────────────────────────────────────────────────
[ObservableProperty] private string _companyName = string.Empty;
[ObservableProperty] private string _companyInfo = string.Empty;
[ObservableProperty] private string _reportLogoPath = string.Empty;
// ── K-Line ────────────────────────────────────────────────────────────
[ObservableProperty] private string _selectedKLinePort = string.Empty;
// ── Advanced (refresh intervals) ──────────────────────────────────────
[ObservableProperty] private int _refreshBenchInterfaceMs = 20;
[ObservableProperty] private int _refreshWhileReadingMs = 1500;
[ObservableProperty] private int _refreshCanBusReadMs = 2;
[ObservableProperty] private int _refreshPumpRequestMs = 250;
[ObservableProperty] private int _refreshPumpParamsMs = 4;
[ObservableProperty] private int _blinkIntervalMs = 1000;
[ObservableProperty] private int _flasherIntervalMs = 800;
// ── Constructor ───────────────────────────────────────────────────────
/// <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)
{
_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
EnumerateFtdiDevices();
}
// ── Commands ──────────────────────────────────────────────────────────
/// <summary>Copies all local values back to AppSettings, saves, and closes.</summary>
[RelayCommand]
private void Accept()
{
var s = _config.Settings;
// General
s.DaysKeepLogs = DaysKeepLogs;
// Safety
s.TempMax = TempMax;
s.TempMin = TempMin;
s.SecurityRpmLimit = SecurityRpmLimit;
s.MaxPressureBar = MaxPressureBar;
s.ToleranceUpExtension = ToleranceUpExtension;
s.TolerancePfpExtension = TolerancePfpExtension;
s.DefaultIgnoreTin = DefaultIgnoreTin;
// PID
s.PidP = PidP;
s.PidI = PidI;
s.PidD = PidD;
s.PidLoopMs = PidLoopMs;
// Motor
s.EncoderResolution = EncoderResolution;
s.VoltageForMaxRpm = VoltageForMaxRpm;
s.MaxRpm = MaxRpm;
s.RightRelayValue = RightRelayValue;
s.Relations = Relations.Select(r => new RpmVoltageRelation(r.Voltage, r.Rpm)).ToList();
// Company
s.CompanyName = CompanyName;
s.CompanyInfo = CompanyInfo;
s.ReportLogoPath = ReportLogoPath;
// K-Line
s.KLinePort = SelectedKLinePort;
// Advanced
s.RefreshBenchInterfaceMs = RefreshBenchInterfaceMs;
s.RefreshWhileReadingMs = RefreshWhileReadingMs;
s.RefreshCanBusReadMs = RefreshCanBusReadMs;
s.RefreshPumpRequestMs = RefreshPumpRequestMs;
s.RefreshPumpParamsMs = RefreshPumpParamsMs;
s.BlinkIntervalMs = BlinkIntervalMs;
s.FlasherIntervalMs = FlasherIntervalMs;
// Language — switch if changed (also persists via LocalizationService)
if (SelectedLanguage != _loc.CurrentLanguage)
_loc.SetLanguage(SelectedLanguage);
_config.SaveSettings();
Accepted = true;
RequestClose?.Invoke();
}
/// <summary>Discards changes and closes.</summary>
[RelayCommand]
private void Cancel()
{
Accepted = false;
RequestClose?.Invoke();
}
/// <summary>Opens a file dialog to select a company logo image.</summary>
[RelayCommand]
private void BrowseLogo()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = _loc.GetString("Dialog.Settings.BrowseLogoTitle"),
Filter = "Image files|*.png;*.jpg;*.jpeg;*.bmp|All files|*.*"
};
if (!string.IsNullOrEmpty(ReportLogoPath))
{
try { dlg.InitialDirectory = System.IO.Path.GetDirectoryName(ReportLogoPath); }
catch { /* ignore invalid path */ }
}
if (dlg.ShowDialog() == true)
ReportLogoPath = dlg.FileName;
}
/// <summary>Re-enumerates connected FTDI devices into <see cref="AvailablePorts"/>.</summary>
[RelayCommand]
private void RefreshPorts()
{
EnumerateFtdiDevices();
}
/// <summary>Appends a new empty row to the RPM-voltage relation table.</summary>
[RelayCommand]
private void AddRelation()
{
Relations.Add(new RpmVoltageRelation(0.0, 0));
}
/// <summary>Removes the selected row from the RPM-voltage relation table.</summary>
[RelayCommand]
private void RemoveRelation(RpmVoltageRelation? relation)
{
if (relation != null)
Relations.Remove(relation);
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>
/// Populates <see cref="AvailablePorts"/> with serial numbers of connected
/// FTDI devices. Fails silently if the FTDI driver DLL is not present.
/// </summary>
private void EnumerateFtdiDevices()
{
AvailablePorts.Clear();
try
{
uint count = FtdiInterface.GetDevicesCount();
if (count == 0) return;
var list = new FT_DEVICE_INFO_NODE[count];
FtdiInterface.GetDeviceList(list);
foreach (var device in list)
{
if (!string.IsNullOrEmpty(device.SerialNumber))
AvailablePorts.Add(device.SerialNumber);
}
}
catch
{
// FTDI DLL not loaded or no devices — leave list empty.
}
}
}
}

View File

@@ -0,0 +1,152 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HC_APTBS.Services;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the Ford VP44 immobilizer unlock progress dialog.
/// Tracks Phase 1 (CAN flood ~600 s), Phase 2 (handshake ~4 s), and verification.
/// Equivalent to the old <c>WUnlocker</c> window.
/// </summary>
public sealed partial class UnlockProgressViewModel : ObservableObject, IDisposable
{
private readonly IUnlockService _unlockService;
private readonly ILocalizationService _loc;
private readonly CancellationTokenSource _cts;
/// <summary>Regex to extract percentage and elapsed time from Phase 1 status messages.</summary>
private static readonly Regex ProgressRegex =
new(@"Unlocking\.\.\. (\d+)% \((\d{2}:\d{2})\)", RegexOptions.Compiled);
/// <summary>Creates the ViewModel and subscribes to unlock service events.</summary>
/// <param name="unlockService">The unlock service to monitor.</param>
/// <param name="unlockType">Pump unlock type (1 or 2).</param>
/// <param name="cts">Cancellation token source to cancel the unlock.</param>
public UnlockProgressViewModel(IUnlockService unlockService, int unlockType, CancellationTokenSource cts, ILocalizationService loc)
{
_unlockService = unlockService;
_loc = loc;
_cts = cts;
_unlockTypeLabel = string.Format(_loc.GetString("Dialog.Unlock.TypeLabel"), unlockType);
_phaseText = _loc.GetString("Dialog.Unlock.Phase1");
_elapsedTime = "00:00";
_isCancellable = true;
_unlockService.StatusChanged += OnStatusChanged;
_unlockService.UnlockCompleted += OnUnlockCompleted;
}
// ── Observable properties ────────────────────────────────────────────────
/// <summary>Progress percentage (0100).</summary>
[ObservableProperty] private int _progress;
/// <summary>Elapsed time formatted as MM:SS.</summary>
[ObservableProperty] private string _elapsedTime;
/// <summary>Current phase description.</summary>
[ObservableProperty] private string _phaseText;
/// <summary>Result text shown after completion.</summary>
[ObservableProperty] private string _resultText = string.Empty;
/// <summary>Label for unlock type (e.g. "Type 1").</summary>
[ObservableProperty] private string _unlockTypeLabel;
/// <summary>True when the unlock sequence has finished (success, failure, or cancelled).</summary>
[NotifyCanExecuteChangedFor(nameof(CloseCommand))]
[ObservableProperty] private bool _isComplete;
/// <summary>True while cancellation is allowed (Phase 1 only).</summary>
[NotifyCanExecuteChangedFor(nameof(CancelCommand))]
[ObservableProperty] private bool _isCancellable;
/// <summary>Tri-state result: null = in progress, true = success, false = failure.</summary>
[ObservableProperty] private bool? _isSuccess;
// ── Commands ─────────────────────────────────────────────────────────────
/// <summary>Cancels the unlock sequence (only available during Phase 1).</summary>
[RelayCommand(CanExecute = nameof(IsCancellable))]
private void Cancel()
{
_cts.Cancel();
IsCancellable = false;
IsComplete = true;
IsSuccess = false;
ResultText = _loc.GetString("Dialog.Unlock.Cancelled");
}
/// <summary>Closes the dialog (only available after completion).</summary>
[RelayCommand(CanExecute = nameof(IsComplete))]
private void Close()
{
RequestClose?.Invoke();
}
// ── Events ───────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event Action? RequestClose;
// ── Service event handlers ───────────────────────────────────────────────
private void OnStatusChanged(string msg)
{
Application.Current?.Dispatcher?.Invoke(() =>
{
var match = ProgressRegex.Match(msg);
if (match.Success)
{
Progress = int.Parse(match.Groups[1].Value);
ElapsedTime = match.Groups[2].Value;
return;
}
if (msg == "Fast unlock attempt...")
{
PhaseText = _loc.GetString("Dialog.Unlock.FastAttempt");
}
else if (msg == "Unlocking...")
{
PhaseText = _loc.GetString("Dialog.Unlock.Phase1");
}
else if (msg == "Testing unlock...")
{
PhaseText = _loc.GetString("Dialog.Unlock.Phase2Testing");
IsCancellable = false;
Progress = 100;
}
else if (msg == "Sending...")
{
PhaseText = _loc.GetString("Dialog.Unlock.Phase2Sending");
}
});
}
private void OnUnlockCompleted(bool success)
{
Application.Current?.Dispatcher?.Invoke(() =>
{
IsComplete = true;
IsCancellable = false;
IsSuccess = success;
ResultText = success ? _loc.GetString("Dialog.Unlock.Unlocked") : _loc.GetString("Dialog.Unlock.Failed");
});
}
// ── IDisposable ──────────────────────────────────────────────────────────
/// <summary>Unsubscribes from service events to prevent leaks.</summary>
public void Dispose()
{
_unlockService.StatusChanged -= OnStatusChanged;
_unlockService.UnlockCompleted -= OnUnlockCompleted;
}
}
}

View File

@@ -12,11 +12,13 @@ namespace HC_APTBS.ViewModels.Dialogs
public sealed partial class UserCheckViewModel : ObservableObject
{
private readonly IConfigurationService _config;
private readonly ILocalizationService _loc;
/// <summary>Initialises the dialog, optionally pre-filling the last used username.</summary>
public UserCheckViewModel(IConfigurationService config, string lastUsername = "")
public UserCheckViewModel(IConfigurationService config, ILocalizationService loc, string lastUsername = "")
{
_config = config;
_loc = loc;
_username = lastUsername;
}
@@ -55,8 +57,8 @@ namespace HC_APTBS.ViewModels.Dialogs
else
{
MessageBox.Show(
"Invalid username or password.\n(Both are case-sensitive.)",
"Authentication Error",
_loc.GetString("Error.AuthInvalid"),
_loc.GetString("Error.AuthTitle"),
MessageBoxButton.OK,
MessageBoxImage.Stop);
}

View File

@@ -0,0 +1,41 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace HC_APTBS.ViewModels.Dialogs
{
/// <summary>
/// ViewModel for the voltage warning dialog shown when a pump requiring
/// a specific supply voltage (27 V or 13.5 V) is selected.
/// Equivalent to the old <c>WAlert27v</c> dialog.
/// </summary>
public sealed partial class VoltageWarningViewModel : ObservableObject
{
/// <summary>
/// Creates the voltage warning ViewModel for the specified voltage.
/// </summary>
/// <param name="voltage">Voltage string to display (e.g. "27 V" or "13.5 V").</param>
public VoltageWarningViewModel(string voltage)
{
Voltage = voltage;
}
// ── Display properties ────────────────────────────────────────────────────
/// <summary>The required voltage string shown in the dialog (e.g. "27 V").</summary>
[ObservableProperty] private string _voltage = string.Empty;
// ── Commands ──────────────────────────────────────────────────────────────
/// <summary>Acknowledges the warning and closes the dialog.</summary>
[RelayCommand]
private void Acknowledge()
{
RequestClose?.Invoke();
}
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when the dialog should close itself.</summary>
public event System.Action? RequestClose;
}
}