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:
@@ -36,6 +36,13 @@ namespace HC_APTBS.Services
|
||||
/// </summary>
|
||||
event Action<string , bool >? PhaseCompleted; // phaseName,passed
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a safety check triggers an emergency stop. The bench motor
|
||||
/// and pump parameters are already stopped when this fires.
|
||||
/// Fires on a background thread — consumers must marshal to the UI thread.
|
||||
/// </summary>
|
||||
event Action<string >? EmergencyStopTriggered; //reason
|
||||
|
||||
// ── Active pump ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -213,5 +220,11 @@ namespace HC_APTBS.Services
|
||||
/// Raised so the chart view can draw tolerance bands for the specified parameter.
|
||||
/// </summary>
|
||||
event Action<string , double , double >? ToleranceUpdated; //parameterName, value, tolerance
|
||||
|
||||
/// <summary>
|
||||
/// Raised for each individual measurement sample collected during a test phase.
|
||||
/// Fires on a background thread — consumers must marshal to the UI thread.
|
||||
/// </summary>
|
||||
event Action<string , double >? MeasurementSampled; //parameterName, value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using HC_APTBS.Models;
|
||||
|
||||
namespace HC_APTBS.Services
|
||||
@@ -73,8 +74,8 @@ namespace HC_APTBS.Services
|
||||
/// <summary>Validates a username/password pair against stored credentials.</summary>
|
||||
bool ValidateUser(string username, string password);
|
||||
|
||||
/// <summary>Returns all stored user credentials as a dictionary.</summary>
|
||||
IReadOnlyDictionary<string, string> GetUsers();
|
||||
/// <summary>Returns all stored usernames (passwords are never exposed).</summary>
|
||||
IReadOnlyList<string> GetUsers();
|
||||
|
||||
/// <summary>Replaces all stored user credentials and persists them.</summary>
|
||||
void UpdateUsers(Dictionary<string, string> users);
|
||||
|
||||
@@ -126,6 +126,16 @@ namespace HC_APTBS.Services
|
||||
/// </summary>
|
||||
event Action<double>? DfiRead;
|
||||
|
||||
// ── Fast immobilizer unlock ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Attempts a fast immobilizer unlock by sending a KWP custom command
|
||||
/// over an existing K-Line session. Returns <see langword="true"/> if the
|
||||
/// command was acknowledged (pump already unlocked), <see langword="false"/>
|
||||
/// if it was rejected or no session is active.
|
||||
/// </summary>
|
||||
Task<bool> TryFastUnlockAsync();
|
||||
|
||||
// ── Power cycle callbacks ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
39
Services/ILocalizationService.cs
Normal file
39
Services/ILocalizationService.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace HC_APTBS.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides runtime language switching and localised string retrieval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// XAML bindings use <c>{DynamicResource Key}</c> which update automatically
|
||||
/// when the merged <see cref="System.Windows.ResourceDictionary"/> is swapped.
|
||||
/// C# code uses <see cref="GetString"/> for the same keys.
|
||||
/// </remarks>
|
||||
public interface ILocalizationService
|
||||
{
|
||||
/// <summary>Current language code — <c>"ESP"</c> or <c>"ENG"</c>.</summary>
|
||||
string CurrentLanguage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Switches the active UI language by swapping the merged resource dictionary
|
||||
/// and persisting the choice to <c>config.xml</c>.
|
||||
/// Must be called from the UI thread.
|
||||
/// </summary>
|
||||
/// <param name="languageCode"><c>"ESP"</c> for Spanish or <c>"ENG"</c> for English.</param>
|
||||
void SetLanguage(string languageCode);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a localised string by resource key.
|
||||
/// Returns the key itself when no matching resource is found (fail-visible).
|
||||
/// </summary>
|
||||
/// <param name="key">Resource key defined in <c>Resources/Strings.*.xaml</c>.</param>
|
||||
string GetString(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Raised after the active language dictionary has been swapped.
|
||||
/// ViewModels subscribe to refresh any cached localised strings.
|
||||
/// </summary>
|
||||
event Action? LanguageChanged;
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,18 @@ namespace HC_APTBS.Services
|
||||
/// <summary>
|
||||
/// Runs the immobilizer unlock sequence for the given pump.
|
||||
/// Returns immediately if <see cref="PumpDefinition.UnlockType"/> is 0 (no unlock needed).
|
||||
/// The persistent CAN senders remain active after this method returns;
|
||||
/// call <see cref="StopSenders"/> when the pump is deselected.
|
||||
/// </summary>
|
||||
/// <param name="pump">Pump definition with unlock type and CAN parameters.</param>
|
||||
/// <param name="ct">Cancellation token to abort the unlock sequence.</param>
|
||||
Task UnlockAsync(PumpDefinition pump, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the persistent CAN unlock senders. Call this when the pump is
|
||||
/// deselected or the application is shutting down. Safe to call when no
|
||||
/// senders are active.
|
||||
/// </summary>
|
||||
void StopSenders();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ namespace HC_APTBS.Services.Impl
|
||||
private CancellationTokenSource? _relaySendCts;
|
||||
private volatile bool _relaySendActive;
|
||||
|
||||
// Alarm bitmask snapshot for edge detection during test phases
|
||||
private int _lastAlarmMask;
|
||||
|
||||
// QOver zero-flow safety debounce (elapsed ms from phase stopwatch)
|
||||
private long _qOverZeroSinceMs;
|
||||
private const int QOverDebounceSec = 3;
|
||||
|
||||
// RPM PID ramp controller
|
||||
private BenchPidController? _pidController;
|
||||
private double _lastTargetRpm;
|
||||
@@ -92,6 +99,10 @@ namespace HC_APTBS.Services.Impl
|
||||
public event Action<string, double>? PumpControlValueSet;
|
||||
/// <inheritdoc/>
|
||||
public event Action? RpmCommandSent;
|
||||
/// <inheritdoc/>
|
||||
public event Action<string, double>? MeasurementSampled;
|
||||
/// <inheritdoc/>
|
||||
public event Action<string>? EmergencyStopTriggered;
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -615,6 +626,7 @@ namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
_cts?.Cancel();
|
||||
SetRpm(0);
|
||||
ZeroPumpParameters();
|
||||
_log.Info(LogId, "Test sequence stopped by operator.");
|
||||
}
|
||||
|
||||
@@ -633,6 +645,8 @@ namespace HC_APTBS.Services.Impl
|
||||
phase.Success = true;
|
||||
phase.ClearResults();
|
||||
phase.ErrorBits.Clear();
|
||||
_lastAlarmMask = (int)ReadBenchParameter(BenchParameterNames.Alarms);
|
||||
_qOverZeroSinceMs = 0;
|
||||
|
||||
// SVME test: check that the PSG encoder sync pulse is present before proceeding.
|
||||
if (!phase.Name.Contains("Lock Angle") &&
|
||||
@@ -727,6 +741,8 @@ namespace HC_APTBS.Services.Impl
|
||||
for (int i = 0; i * 1000 < conditioningRemainMs; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
CheckQOverSafety(i * 1000L);
|
||||
PollAlarms(phase);
|
||||
int remaining = (int)(conditioningRemainMs / 1000) - i;
|
||||
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s");
|
||||
await Task.Delay(1000, ct);
|
||||
@@ -768,12 +784,14 @@ namespace HC_APTBS.Services.Impl
|
||||
if (phase.IsCritical && !phase.Success)
|
||||
{
|
||||
SetRpm(0);
|
||||
ZeroPumpParameters();
|
||||
VerboseMessage?.Invoke($"CRITICAL failure in {phase.Name} — test halted.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop pump between phases (motor cool-down).
|
||||
SetRpm(0);
|
||||
ZeroPumpParameters();
|
||||
}
|
||||
|
||||
return success;
|
||||
@@ -799,6 +817,7 @@ namespace HC_APTBS.Services.Impl
|
||||
while (sw.ElapsedMilliseconds <= measureMs)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
CheckQOverSafety(sw.ElapsedMilliseconds);
|
||||
|
||||
foreach (var tp in phase.Receives)
|
||||
{
|
||||
@@ -808,8 +827,10 @@ namespace HC_APTBS.Services.Impl
|
||||
Timestamp = DateTime.Now.ToString(TestDefinition.TimestampFormat)
|
||||
};
|
||||
tp.Result!.AddSample(sample);
|
||||
MeasurementSampled?.Invoke(tp.Name, sample.Value);
|
||||
}
|
||||
|
||||
PollAlarms(phase);
|
||||
await Task.Delay(sleepMs, ct);
|
||||
}
|
||||
|
||||
@@ -879,6 +900,114 @@ namespace HC_APTBS.Services.Impl
|
||||
return target.Result?.Passed ?? false;
|
||||
}
|
||||
|
||||
// ── Safety helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Immediately zeroes all pump control parameters (ME, FBKW, PreIn) and
|
||||
/// transmits the zero values over CAN. Bypasses the slew-rate IIR filter
|
||||
/// by writing directly to parameter values and clearing the target fields.
|
||||
/// </summary>
|
||||
private void ZeroPumpParameters()
|
||||
{
|
||||
if (_activePump == null) return;
|
||||
|
||||
// Zero the slew-rate targets so the periodic sender doesn't ramp back up.
|
||||
_targetMe = 0;
|
||||
_targetFbkw = 0;
|
||||
_targetPreIn = 0;
|
||||
|
||||
// Write zero directly to the parameter values (bypassing the IIR filter).
|
||||
CanBusParameter? meParam = null;
|
||||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Me, out meParam))
|
||||
{
|
||||
meParam.Value = 0;
|
||||
_can.SendMessageById(meParam.MessageId);
|
||||
}
|
||||
|
||||
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
|
||||
{
|
||||
fbkwParam.Value = 0;
|
||||
if (meParam == null || fbkwParam.MessageId != meParam.MessageId)
|
||||
_can.SendMessageById(fbkwParam.MessageId);
|
||||
}
|
||||
|
||||
if (_activePump.HasPreInjection &&
|
||||
_activePump.ParametersByName.TryGetValue(PumpParameterNames.PreIn, out var preinParam))
|
||||
{
|
||||
preinParam.Value = 0;
|
||||
_can.SendMessageById(preinParam.MessageId);
|
||||
}
|
||||
|
||||
_log.Debug(LogId, "ZeroPumpParameters: ME, FBKW, PreIn zeroed and transmitted.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the QOver zero-flow condition. If QOver reads 0 while the bench
|
||||
/// motor is above 300 RPM and the oil pump relay is energised, and this
|
||||
/// condition persists for <see cref="QOverDebounceSec"/> seconds, triggers
|
||||
/// an emergency stop.
|
||||
/// </summary>
|
||||
/// <param name="elapsedMs">Current elapsed milliseconds (from the phase stopwatch or loop counter).</param>
|
||||
private void CheckQOverSafety(long elapsedMs)
|
||||
{
|
||||
double qOver = ReadBenchParameter(BenchParameterNames.QOver);
|
||||
double benchRpm = ReadBenchParameter(BenchParameterNames.BenchRpm);
|
||||
bool oilPumpOn = _config.Bench.Relays.TryGetValue(RelayNames.OilPump, out var relay)
|
||||
&& relay.State;
|
||||
|
||||
if (qOver == 0 && benchRpm > 300 && oilPumpOn)
|
||||
{
|
||||
if (_qOverZeroSinceMs == 0)
|
||||
_qOverZeroSinceMs = elapsedMs;
|
||||
else if (elapsedMs - _qOverZeroSinceMs >= QOverDebounceSec * 1000)
|
||||
{
|
||||
_log.Error(LogId,
|
||||
$"QOver zero-flow safety: QOver=0, BenchRPM={benchRpm:F0}, " +
|
||||
$"OilPump=ON for {QOverDebounceSec}s — emergency stop.");
|
||||
PerformEmergencyStop("QOver zero-flow: oil flow blocked while motor running");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_qOverZeroSinceMs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediately stops the bench motor, zeroes pump parameters, cancels the
|
||||
/// test sequence, and fires <see cref="EmergencyStopTriggered"/>.
|
||||
/// </summary>
|
||||
private void PerformEmergencyStop(string reason)
|
||||
{
|
||||
SetRpm(0);
|
||||
ZeroPumpParameters();
|
||||
_cts?.Cancel();
|
||||
EmergencyStopTriggered?.Invoke(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current alarm bitmask from the Alarms CAN parameter, detects
|
||||
/// bits that transitioned 0→1 since the last snapshot, and records them
|
||||
/// in the given phase via <see cref="PhaseDefinition.RecordErrorBit"/>.
|
||||
/// </summary>
|
||||
private void PollAlarms(PhaseDefinition phase)
|
||||
{
|
||||
int currentMask = (int)ReadBenchParameter(BenchParameterNames.Alarms);
|
||||
int newBits = currentMask & ~_lastAlarmMask;
|
||||
if (newBits != 0)
|
||||
{
|
||||
for (int bit = 0; bit < 16; bit++)
|
||||
{
|
||||
if ((newBits & (1 << bit)) != 0)
|
||||
{
|
||||
phase.RecordErrorBit(bit);
|
||||
_log.Debug(LogId, $"Alarm bit {bit} recorded in phase {phase.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_lastAlarmMask = currentMask;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task WaitForParameter(
|
||||
|
||||
@@ -2,6 +2,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Xml.Linq;
|
||||
using HC_APTBS.Models;
|
||||
using Peak.Can.Basic;
|
||||
@@ -460,6 +462,18 @@ namespace HC_APTBS.Services.Impl
|
||||
_log.Error(LogId, $"LoadSettings failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Seed default admin account if no users are configured.
|
||||
if (string.IsNullOrEmpty(_settings.Users))
|
||||
{
|
||||
var (salt, hash) = HashPassword("admin");
|
||||
_settings.Users = $"admin:{salt}:{hash}";
|
||||
_log.Info(LogId, "No users configured — created default 'admin' account.");
|
||||
SaveSettings();
|
||||
}
|
||||
|
||||
// Migrate plaintext user:password entries to hashed format.
|
||||
MigrateUsersIfNeeded();
|
||||
|
||||
LoadSensors();
|
||||
LoadClients();
|
||||
LoadAlarms();
|
||||
@@ -742,7 +756,83 @@ namespace HC_APTBS.Services.Impl
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
// ── Users (PBKDF2-HMAC-SHA256 hashed credentials) ─────────────────────────
|
||||
|
||||
private const int SaltBytes = 16;
|
||||
private const int HashBytes = 32;
|
||||
private const int Pbkdf2Iterations = 600_000;
|
||||
|
||||
/// <summary>Generates a random salt and computes the PBKDF2-HMAC-SHA256 hash for <paramref name="password"/>.</summary>
|
||||
private static (string salt, string hash) HashPassword(string password)
|
||||
{
|
||||
byte[] salt = RandomNumberGenerator.GetBytes(SaltBytes);
|
||||
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
|
||||
return (Convert.ToBase64String(salt), Convert.ToBase64String(hash));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <paramref name="password"/> against the given Base64 <paramref name="salt"/> and <paramref name="expectedHash"/>.</summary>
|
||||
private static bool VerifyPassword(string password, string salt, string expectedHash)
|
||||
{
|
||||
byte[] saltBytes = Convert.FromBase64String(salt);
|
||||
byte[] computed = Rfc2898DeriveBytes.Pbkdf2(
|
||||
password, saltBytes, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashBytes);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, Convert.FromBase64String(expectedHash));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether <see cref="AppSettings.Users"/> contains legacy plaintext
|
||||
/// <c>user:password</c> entries and migrates them to <c>user:salt:hash</c>.
|
||||
/// </summary>
|
||||
private void MigrateUsersIfNeeded()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Settings.Users))
|
||||
return;
|
||||
|
||||
string[] entries = Settings.Users.Split(',');
|
||||
bool hasLegacy = false;
|
||||
|
||||
foreach (string entry in entries)
|
||||
{
|
||||
// New format always has exactly 3 colon-separated parts (user:salt:hash).
|
||||
// Legacy format has exactly 2 parts (user:password).
|
||||
// Base64 salt/hash never contain commas but may contain '=' padding —
|
||||
// they will NOT contain additional colons, so Split(':') count is reliable.
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
hasLegacy = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasLegacy) return;
|
||||
|
||||
var migrated = new List<string>(entries.Length);
|
||||
foreach (string entry in entries)
|
||||
{
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 2 && parts[0].Length > 0)
|
||||
{
|
||||
// Legacy entry — hash the plaintext password.
|
||||
var (salt, hash) = HashPassword(parts[1]);
|
||||
migrated.Add($"{parts[0]}:{salt}:{hash}");
|
||||
}
|
||||
else if (parts.Length == 3 && parts[0].Length > 0)
|
||||
{
|
||||
// Already migrated entry — keep as-is.
|
||||
migrated.Add(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Warning(LogId, $"Skipped malformed user entry during migration: '{entry}'");
|
||||
}
|
||||
}
|
||||
|
||||
Settings.Users = string.Join(",", migrated);
|
||||
SaveSettings();
|
||||
_log.Info(LogId, $"Migrated {entries.Length} user credential(s) from plaintext to PBKDF2 hashed format.");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ValidateUser(string username, string password)
|
||||
@@ -750,25 +840,26 @@ namespace HC_APTBS.Services.Impl
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||
return false;
|
||||
|
||||
string check = username + ":" + password;
|
||||
foreach (string entry in Settings.Users.Split(','))
|
||||
{
|
||||
if (entry == check) return true;
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 3 && parts[0] == username)
|
||||
return VerifyPassword(password, parts[1], parts[2]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string> GetUsers()
|
||||
public IReadOnlyList<string> GetUsers()
|
||||
{
|
||||
var dict = new Dictionary<string, string>();
|
||||
foreach (string kv in Settings.Users.Split(','))
|
||||
var names = new List<string>();
|
||||
foreach (string entry in Settings.Users.Split(','))
|
||||
{
|
||||
string[] parts = kv.Split(':');
|
||||
if (parts.Length == 2 && parts[0].Length > 0)
|
||||
dict[parts[0]] = parts[1];
|
||||
string[] parts = entry.Split(':');
|
||||
if (parts.Length == 3 && parts[0].Length > 0)
|
||||
names.Add(parts[0]);
|
||||
}
|
||||
return dict;
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -776,7 +867,10 @@ namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
var entries = new List<string>(users.Count);
|
||||
foreach (var kv in users)
|
||||
entries.Add(kv.Key + ":" + kv.Value);
|
||||
{
|
||||
var (salt, hash) = HashPassword(kv.Value);
|
||||
entries.Add($"{kv.Key}:{salt}:{hash}");
|
||||
}
|
||||
|
||||
Settings.Users = string.Join(",", entries);
|
||||
SaveSettings();
|
||||
|
||||
@@ -608,6 +608,39 @@ namespace HC_APTBS.Services.Impl
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── IKwpService: fast immobilizer unlock ──────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> TryFastUnlockAsync()
|
||||
{
|
||||
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
|
||||
{
|
||||
_log.Info(LogId, "TryFastUnlock: no active K-Line session — skipping");
|
||||
return false;
|
||||
}
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_log.Info(LogId, "TryFastUnlock: sending unlock command over K-Line");
|
||||
var packets = _sessionKwp.SendCustom(
|
||||
new List<byte> { 0x02, 0x88, 0x02, 0x03, 0xA8, 0x01, 0x00 });
|
||||
|
||||
bool nak = packets.Count == 1
|
||||
&& packets[0] is HC_APTBS.Infrastructure.Kwp.Packets.NakPacket;
|
||||
|
||||
_log.Info(LogId, $"TryFastUnlock: {(nak ? "NAK — pump rejected" : "ACK — pump unlocked")}");
|
||||
return !nak;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(LogId, $"TryFastUnlock failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── IKwpService: device detection ────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
83
Services/Impl/LocalizationService.cs
Normal file
83
Services/Impl/LocalizationService.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
|
||||
namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages runtime language switching by swapping a merged
|
||||
/// <see cref="ResourceDictionary"/> in <see cref="Application.Current"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On construction the service reads <see cref="Models.AppSettings.Language"/>
|
||||
/// and loads the corresponding dictionary. Subsequent calls to
|
||||
/// <see cref="SetLanguage"/> replace it in-place and persist the preference.
|
||||
/// </remarks>
|
||||
public sealed class LocalizationService : ILocalizationService
|
||||
{
|
||||
private const string EspUri = "pack://application:,,,/Resources/Strings.es.xaml";
|
||||
private const string EngUri = "pack://application:,,,/Resources/Strings.en.xaml";
|
||||
|
||||
private readonly IConfigurationService _config;
|
||||
private ResourceDictionary? _currentDictionary;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CurrentLanguage { get; private set; } = "ESP";
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action? LanguageChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the localization service and loads the language
|
||||
/// stored in <see cref="Models.AppSettings.Language"/>.
|
||||
/// </summary>
|
||||
public LocalizationService(IConfigurationService config)
|
||||
{
|
||||
_config = config;
|
||||
// Load persisted language without saving (already persisted).
|
||||
LoadDictionary(_config.Settings.Language);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetLanguage(string languageCode)
|
||||
{
|
||||
var code = NormaliseCode(languageCode);
|
||||
if (code == CurrentLanguage)
|
||||
return;
|
||||
|
||||
LoadDictionary(code);
|
||||
|
||||
// Persist the choice.
|
||||
_config.Settings.Language = code;
|
||||
_config.SaveSettings();
|
||||
|
||||
LanguageChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetString(string key)
|
||||
{
|
||||
return Application.Current.Resources[key]?.ToString() ?? key;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void LoadDictionary(string languageCode)
|
||||
{
|
||||
var code = NormaliseCode(languageCode);
|
||||
var uri = code == "ENG" ? EngUri : EspUri;
|
||||
|
||||
var dict = new ResourceDictionary { Source = new Uri(uri, UriKind.Absolute) };
|
||||
|
||||
var merged = Application.Current.Resources.MergedDictionaries;
|
||||
if (_currentDictionary != null)
|
||||
merged.Remove(_currentDictionary);
|
||||
|
||||
merged.Add(dict);
|
||||
_currentDictionary = dict;
|
||||
CurrentLanguage = code;
|
||||
}
|
||||
|
||||
private static string NormaliseCode(string code) =>
|
||||
string.Equals(code, "ENG", StringComparison.OrdinalIgnoreCase) ? "ENG" : "ESP";
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,15 @@ namespace HC_APTBS.Services.Impl
|
||||
public sealed class PdfService : IPdfService
|
||||
{
|
||||
private readonly IConfigurationService _config;
|
||||
private readonly ILocalizationService _loc;
|
||||
private readonly byte[]? _defaultLogo;
|
||||
|
||||
/// <param name="configService">Provides company name, logo path, and report settings.</param>
|
||||
public PdfService(IConfigurationService configService)
|
||||
/// <param name="localizationService">Provides localised strings for report text.</param>
|
||||
public PdfService(IConfigurationService configService, ILocalizationService localizationService)
|
||||
{
|
||||
_config = configService;
|
||||
_loc = localizationService;
|
||||
|
||||
// QuestPDF community licence — required for open-source use.
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
@@ -121,11 +124,11 @@ namespace HC_APTBS.Services.Impl
|
||||
// Date / operator / client block.
|
||||
row.ConstantItem(140).AlignRight().Column(col =>
|
||||
{
|
||||
col.Item().Text($"Date: {reportDate:dd/MM/yyyy HH:mm}")
|
||||
col.Item().Text(string.Format(_loc.GetString("Pdf.Date"), reportDate))
|
||||
.FontSize(ReportTheme.CaptionSize + 1);
|
||||
col.Item().Text($"Operator: {operatorName}")
|
||||
col.Item().Text(string.Format(_loc.GetString("Pdf.Operator"), operatorName))
|
||||
.FontSize(ReportTheme.CaptionSize + 1);
|
||||
col.Item().Text($"Client: {clientName}")
|
||||
col.Item().Text(string.Format(_loc.GetString("Pdf.Client"), clientName))
|
||||
.FontSize(ReportTheme.CaptionSize + 1).Bold();
|
||||
});
|
||||
});
|
||||
@@ -137,7 +140,7 @@ namespace HC_APTBS.Services.Impl
|
||||
// Report title.
|
||||
outer.Item().PaddingTop(4).PaddingBottom(2)
|
||||
.AlignCenter()
|
||||
.Text("VP44 INJECTION PUMP TEST REPORT")
|
||||
.Text(_loc.GetString("Pdf.ReportTitle"))
|
||||
.Bold().FontSize(ReportTheme.SectionHeaderSize)
|
||||
.FontColor(ReportTheme.HeaderNavy);
|
||||
});
|
||||
@@ -146,23 +149,23 @@ namespace HC_APTBS.Services.Impl
|
||||
// ── Footer ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the page footer: divider, attribution, and page numbers.</summary>
|
||||
private static void ComposeFooter(IContainer container)
|
||||
private void ComposeFooter(IContainer container)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Item().LineHorizontal(0.5f).LineColor(ReportTheme.DividerLine);
|
||||
col.Item().PaddingTop(3).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text("Generated by HC-APTBS")
|
||||
row.RelativeItem().Text(_loc.GetString("Pdf.GeneratedBy"))
|
||||
.FontSize(ReportTheme.FooterSize)
|
||||
.FontColor(ReportTheme.HeaderGrey);
|
||||
|
||||
row.ConstantItem(100).AlignRight().Text(t =>
|
||||
{
|
||||
t.DefaultTextStyle(x => x.FontSize(ReportTheme.FooterSize));
|
||||
t.Span("Page ");
|
||||
t.Span(_loc.GetString("Pdf.Page"));
|
||||
t.CurrentPageNumber();
|
||||
t.Span(" of ");
|
||||
t.Span(_loc.GetString("Pdf.Of"));
|
||||
t.TotalPages();
|
||||
});
|
||||
});
|
||||
@@ -172,7 +175,7 @@ namespace HC_APTBS.Services.Impl
|
||||
// ── Content ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Composes the full report body: pump info, ECU data, verdict, test sections.</summary>
|
||||
private static void ComposeContent(IContainer container, PumpDefinition pump)
|
||||
private void ComposeContent(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
container.PaddingTop(6).Column(col =>
|
||||
{
|
||||
@@ -202,7 +205,7 @@ namespace HC_APTBS.Services.Impl
|
||||
// ── Pump info table ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the pump identification table with alternating row stripes.</summary>
|
||||
private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
|
||||
private void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
@@ -219,7 +222,7 @@ namespace HC_APTBS.Services.Impl
|
||||
header.Cell().ColumnSpan(4)
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text("PUMP IDENTIFICATION")
|
||||
.Text(_loc.GetString("Pdf.PumpIdentification"))
|
||||
.FontColor(Colors.White).Bold()
|
||||
.FontSize(ReportTheme.SectionHeaderSize);
|
||||
});
|
||||
@@ -239,20 +242,20 @@ namespace HC_APTBS.Services.Impl
|
||||
.Text(value2).FontSize(ReportTheme.BodySize);
|
||||
}
|
||||
|
||||
AddRow("Pump ID:", pump.Id, "Model:", pump.Model);
|
||||
AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector);
|
||||
AddRow("Tube:", pump.Tube, "Valve:", pump.Valve);
|
||||
AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation);
|
||||
AddRow("Lock Angle:", $"{pump.LockAngle:F2}\u00B0",
|
||||
"Measured:", $"{pump.LockAngleResult:F2}\u00B0");
|
||||
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No");
|
||||
AddRow(_loc.GetString("Pdf.PumpId"), pump.Id, _loc.GetString("Pdf.Model"), pump.Model);
|
||||
AddRow(_loc.GetString("Pdf.SerialNo"), pump.SerialNumber, _loc.GetString("Pdf.Injector"), pump.Injector);
|
||||
AddRow(_loc.GetString("Pdf.Tube"), pump.Tube, _loc.GetString("Pdf.Valve"), pump.Valve);
|
||||
AddRow(_loc.GetString("Pdf.Tension"), pump.Tension, _loc.GetString("Pdf.Rotation"), pump.Rotation);
|
||||
AddRow(_loc.GetString("Pdf.LockAngle"), $"{pump.LockAngle:F2}\u00B0",
|
||||
_loc.GetString("Pdf.Measured"), $"{pump.LockAngleResult:F2}\u00B0");
|
||||
AddRow(_loc.GetString("Pdf.Chaveta"), pump.Chaveta, _loc.GetString("Pdf.PreInj"), pump.HasPreInjection ? _loc.GetString("Common.Yes") : _loc.GetString("Common.No"));
|
||||
});
|
||||
}
|
||||
|
||||
// ── K-Line table ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the K-Line ECU data table with alternating row stripes.</summary>
|
||||
private static void ComposeKlineTable(IContainer container, PumpDefinition pump)
|
||||
private void ComposeKlineTable(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
@@ -269,7 +272,7 @@ namespace HC_APTBS.Services.Impl
|
||||
header.Cell().ColumnSpan(4)
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text("ECU DATA (K-Line)")
|
||||
.Text(_loc.GetString("Pdf.EcuData"))
|
||||
.FontColor(Colors.White).Bold()
|
||||
.FontSize(ReportTheme.SectionHeaderSize);
|
||||
});
|
||||
@@ -301,7 +304,7 @@ namespace HC_APTBS.Services.Impl
|
||||
// ── Verdict section ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders the overall test result badge with summary statistics.</summary>
|
||||
private static void ComposeVerdictSection(IContainer container, PumpDefinition pump)
|
||||
private void ComposeVerdictSection(IContainer container, PumpDefinition pump)
|
||||
{
|
||||
// Compute summary statistics.
|
||||
var testsWithResults = pump.Tests.Where(t => t.HasResults()).ToList();
|
||||
@@ -339,16 +342,16 @@ namespace HC_APTBS.Services.Impl
|
||||
// Summary statistics.
|
||||
row.RelativeItem().PaddingLeft(12).Column(col =>
|
||||
{
|
||||
col.Item().Text("OVERALL TEST RESULT")
|
||||
col.Item().Text(_loc.GetString("Pdf.OverallResult"))
|
||||
.Bold().FontSize(ReportTheme.SectionHeaderSize)
|
||||
.FontColor(ReportTheme.HeaderNavy);
|
||||
|
||||
col.Item().PaddingTop(4).Text(
|
||||
$"Tests executed: {testedCount} of {totalTests}")
|
||||
string.Format(_loc.GetString("Pdf.TestsExecuted"), testedCount, totalTests))
|
||||
.FontSize(ReportTheme.BodySize);
|
||||
|
||||
col.Item().Text(
|
||||
$"Parameters evaluated: {passedPhases} / {totalPhases} passed")
|
||||
string.Format(_loc.GetString("Pdf.ParamsEvaluated"), passedPhases, totalPhases))
|
||||
.FontSize(ReportTheme.BodySize);
|
||||
|
||||
// Per-test mini indicators.
|
||||
@@ -383,7 +386,7 @@ namespace HC_APTBS.Services.Impl
|
||||
// ── Test results section ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Renders a single test: results table followed by measurement charts.</summary>
|
||||
private static void ComposeTestSection(IContainer container, TestDefinition test)
|
||||
private void ComposeTestSection(IContainer container, TestDefinition test)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
@@ -391,7 +394,7 @@ namespace HC_APTBS.Services.Impl
|
||||
col.Item()
|
||||
.Background(ReportTheme.HeaderNavy)
|
||||
.Padding(5)
|
||||
.Text($"TEST: {test.Name}")
|
||||
.Text(string.Format(_loc.GetString("Pdf.TestHeader"), test.Name))
|
||||
.FontColor(Colors.White).Bold()
|
||||
.FontSize(ReportTheme.SectionHeaderSize);
|
||||
|
||||
@@ -405,7 +408,7 @@ namespace HC_APTBS.Services.Impl
|
||||
}
|
||||
|
||||
/// <summary>Renders the pass/fail results table for one test.</summary>
|
||||
private static void ComposeResultsTable(IContainer container, TestDefinition test)
|
||||
private void ComposeResultsTable(IContainer container, TestDefinition test)
|
||||
{
|
||||
container.Table(table =>
|
||||
{
|
||||
@@ -421,7 +424,7 @@ namespace HC_APTBS.Services.Impl
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance \u00B1", "Average", "Result" })
|
||||
foreach (var h in new[] { _loc.GetString("Pdf.Phase"), _loc.GetString("Pdf.Parameter"), _loc.GetString("Pdf.Target"), _loc.GetString("Pdf.ToleranceHeader"), _loc.GetString("Pdf.Average"), _loc.GetString("Pdf.Result") })
|
||||
header.Cell()
|
||||
.Background(ReportTheme.AccentBlue)
|
||||
.Padding(ReportTheme.CellPad)
|
||||
@@ -440,7 +443,7 @@ namespace HC_APTBS.Services.Impl
|
||||
if (tp.Result == null) continue;
|
||||
|
||||
bool passed = tp.Result.Passed;
|
||||
string resultText = passed ? "PASS" : "FAIL";
|
||||
string resultText = passed ? _loc.GetString("Common.Pass") : _loc.GetString("Common.Fail");
|
||||
|
||||
// Alternating base row colour, tinted by pass/fail.
|
||||
string bgColor = passed
|
||||
@@ -471,7 +474,7 @@ namespace HC_APTBS.Services.Impl
|
||||
table.Cell().ColumnSpan(6)
|
||||
.Background(ReportTheme.WarningBg)
|
||||
.Padding(ReportTheme.CellPad)
|
||||
.Text($" \u26A0 Error bits: {string.Join(", ", phase.ErrorBits)}")
|
||||
.Text(string.Format(_loc.GetString("Pdf.ErrorBits"), string.Join(", ", phase.ErrorBits)))
|
||||
.FontSize(ReportTheme.CaptionSize + 1)
|
||||
.FontColor(ReportTheme.WarningText);
|
||||
}
|
||||
@@ -480,7 +483,7 @@ namespace HC_APTBS.Services.Impl
|
||||
}
|
||||
|
||||
/// <summary>Renders measurement charts for each parameter that has sample data.</summary>
|
||||
private static void ComposeTestCharts(IContainer container, TestDefinition test)
|
||||
private void ComposeTestCharts(IContainer container, TestDefinition test)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
@@ -512,11 +515,13 @@ namespace HC_APTBS.Services.Impl
|
||||
.FitWidth();
|
||||
|
||||
// Chart caption.
|
||||
string passFailText = tp.Result.Passed
|
||||
? _loc.GetString("Common.Pass")
|
||||
: _loc.GetString("Common.Fail");
|
||||
col.Item().PaddingBottom(ReportTheme.SubsectionGap)
|
||||
.Text($"Samples: {tp.Result.Samples.Count} | " +
|
||||
$"Target: {tp.Value:F2} \u00B1 {tp.Tolerance:F2} | " +
|
||||
$"Average: {tp.Result.Average:F2} | " +
|
||||
$"Result: {(tp.Result.Passed ? "PASS" : "FAIL")}")
|
||||
.Text(string.Format(_loc.GetString("Pdf.ChartSamples"),
|
||||
tp.Result.Samples.Count, tp.Value, tp.Tolerance,
|
||||
tp.Result.Average, passFailText))
|
||||
.FontSize(ReportTheme.CaptionSize)
|
||||
.FontColor(ReportTheme.HeaderGrey);
|
||||
}
|
||||
@@ -525,7 +530,7 @@ namespace HC_APTBS.Services.Impl
|
||||
if (!anyChart)
|
||||
{
|
||||
col.Item().PaddingTop(2).PaddingBottom(4)
|
||||
.Text("No sample data available for graphical display.")
|
||||
.Text(_loc.GetString("Pdf.NoSampleData"))
|
||||
.FontSize(ReportTheme.CaptionSize).Italic()
|
||||
.FontColor(ReportTheme.HeaderGrey);
|
||||
}
|
||||
|
||||
@@ -7,31 +7,43 @@ namespace HC_APTBS.Services.Impl
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs.
|
||||
/// The unlock has two phases:
|
||||
/// <para>The CAN flood messages must start before unlocking and continue running
|
||||
/// after unlock completes — stopping them causes the pump to re-lock. Call
|
||||
/// <see cref="StopSenders"/> only when the pump is deselected.</para>
|
||||
/// <list type="number">
|
||||
/// <item>Continuous CAN message sends for ~10 minutes (600.5 s)</item>
|
||||
/// <item>A state-machine handshake that cycles through command bytes on 0x700</item>
|
||||
/// <item>Start persistent CAN senders (run until explicitly stopped)</item>
|
||||
/// <item>Begin the 600 s CAN wait with progress reporting</item>
|
||||
/// <item>In parallel, wait for K-Line to become Connected, then try the fast
|
||||
/// unlock (RAM timer shortcut) — if the pump verifies unlocked, cancel
|
||||
/// the remaining wait</item>
|
||||
/// <item>TestUnlock state-machine handshake on 0x700</item>
|
||||
/// <item>Verify via CAN TestUnlock parameter</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class UnlockService : IUnlockService
|
||||
{
|
||||
private readonly ICanService _can;
|
||||
private readonly IKwpService _kwp;
|
||||
private readonly IAppLogger _log;
|
||||
private const string LogId = "UnlockService";
|
||||
|
||||
/// <summary>Total duration of the Phase 1 continuous send (milliseconds).</summary>
|
||||
/// <summary>Total duration of the Phase 1 wait (milliseconds).</summary>
|
||||
private const int UnlockDurationMs = 600_500;
|
||||
|
||||
/// <summary>CTS for the persistent CAN senders — lives beyond <see cref="UnlockAsync"/>.</summary>
|
||||
private CancellationTokenSource? _senderCts;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<string>? StatusChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<bool>? UnlockCompleted;
|
||||
|
||||
/// <summary>Creates the unlock service wired to the CAN bus.</summary>
|
||||
public UnlockService(ICanService canService, IAppLogger logger)
|
||||
/// <summary>Creates the unlock service wired to the CAN and K-Line buses.</summary>
|
||||
public UnlockService(ICanService canService, IKwpService kwpService, IAppLogger logger)
|
||||
{
|
||||
_can = canService;
|
||||
_kwp = kwpService;
|
||||
_log = logger;
|
||||
}
|
||||
|
||||
@@ -41,17 +53,26 @@ namespace HC_APTBS.Services.Impl
|
||||
if (pump.UnlockType == 0) return;
|
||||
|
||||
_log.Info(LogId, $"Starting immobilizer unlock (type {pump.UnlockType}) for {pump.Id}");
|
||||
|
||||
// ── Start persistent CAN senders FIRST ───────────────────────────────
|
||||
// These must be active before any unlock attempt and must continue
|
||||
// running after the unlock completes to prevent re-locking.
|
||||
StartSenders(pump.UnlockType);
|
||||
StatusChanged?.Invoke("Unlocking...");
|
||||
|
||||
// ── Phase 1: Continuous sends for ~10 minutes ─────────────────────────
|
||||
await RunPhase1Async(pump.UnlockType, ct);
|
||||
// ── 600 s CAN wait + parallel K-Line fast unlock attempt ─────────────
|
||||
// The fast unlock shortens the pump's internal 10 min timer via K-Line.
|
||||
// It can only be attempted once the K-Line session is Connected (the
|
||||
// read-all-info must finish first). If the fast unlock succeeds AND
|
||||
// the CAN TestUnlock parameter confirms it, we skip the remaining wait.
|
||||
await WaitWithFastUnlockAsync(pump, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// ── Phase 2: TestUnlock state machine ─────────────────────────────────
|
||||
// ── Phase 2: TestUnlock state machine ────────────────────────────────
|
||||
StatusChanged?.Invoke("Testing unlock...");
|
||||
RunTestUnlockSequence(pump.UnlockType);
|
||||
|
||||
// ── Verify unlock status ──────────────────────────────────────────────
|
||||
// ── Verify unlock status via CAN TestUnlock parameter ────────────────
|
||||
bool success = VerifyUnlock(pump);
|
||||
|
||||
_log.Info(LogId, $"Unlock complete — success={success}");
|
||||
@@ -59,11 +80,27 @@ namespace HC_APTBS.Services.Impl
|
||||
UnlockCompleted?.Invoke(success);
|
||||
}
|
||||
|
||||
// ── Phase 1 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task RunPhase1Async(int unlockType, CancellationToken ct)
|
||||
/// <inheritdoc/>
|
||||
public void StopSenders()
|
||||
{
|
||||
// Build message payloads based on unlock type.
|
||||
if (_senderCts == null) return;
|
||||
_log.Info(LogId, "Stopping persistent CAN unlock senders");
|
||||
_senderCts.Cancel();
|
||||
_senderCts.Dispose();
|
||||
_senderCts = null;
|
||||
}
|
||||
|
||||
// ── Persistent CAN senders ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Starts the two continuous CAN message senders. They run indefinitely
|
||||
/// until <see cref="StopSenders"/> is called (on pump deselection).
|
||||
/// </summary>
|
||||
private void StartSenders(int unlockType)
|
||||
{
|
||||
// Stop any leftover senders from a previous unlock.
|
||||
StopSenders();
|
||||
|
||||
byte[] msg1Data = new byte[8];
|
||||
uint msg1Id;
|
||||
byte[] msg2Data = new byte[8];
|
||||
@@ -88,66 +125,174 @@ namespace HC_APTBS.Services.Impl
|
||||
return;
|
||||
}
|
||||
|
||||
// Run two parallel senders for the full unlock duration.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(UnlockDurationMs);
|
||||
var linkedCt = cts.Token;
|
||||
_senderCts = new CancellationTokenSource();
|
||||
var senderCt = _senderCts.Token;
|
||||
|
||||
var sender1 = Task.Run(async () =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!linkedCt.IsCancellationRequested)
|
||||
while (!senderCt.IsCancellationRequested)
|
||||
{
|
||||
_can.SendRawMessage(msg1Id, msg1Data);
|
||||
await Task.Delay(500, linkedCt);
|
||||
await Task.Delay(500, senderCt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, linkedCt);
|
||||
}, senderCt);
|
||||
|
||||
var sender2 = Task.Run(async () =>
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!linkedCt.IsCancellationRequested)
|
||||
while (!senderCt.IsCancellationRequested)
|
||||
{
|
||||
_can.SendRawMessage(msg2Id, msg2Data);
|
||||
await Task.Delay(50, linkedCt);
|
||||
await Task.Delay(50, senderCt);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, linkedCt);
|
||||
}, senderCt);
|
||||
|
||||
// Report progress periodically.
|
||||
var progressTask = Task.Run(async () =>
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
while (!linkedCt.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, linkedCt);
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
|
||||
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
|
||||
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, linkedCt);
|
||||
_log.Info(LogId, $"Persistent CAN senders started (type {unlockType})");
|
||||
}
|
||||
|
||||
await Task.WhenAll(sender1, sender2, progressTask);
|
||||
// ── Wait with parallel fast-unlock ───────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Runs the 600 s progress wait. In parallel, monitors the K-Line session:
|
||||
/// once it becomes Connected, checks if the pump is still locked, sends the
|
||||
/// fast unlock command, and if the pump verifies unlocked, cancels the
|
||||
/// remaining wait time.
|
||||
/// </summary>
|
||||
private async Task WaitWithFastUnlockAsync(PumpDefinition pump, CancellationToken ct)
|
||||
{
|
||||
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
waitCts.CancelAfter(UnlockDurationMs);
|
||||
var waitCt = waitCts.Token;
|
||||
|
||||
// Progress reporting task.
|
||||
var progressTask = ReportProgressAsync(waitCt);
|
||||
|
||||
// Parallel fast-unlock task — awaits K-Line session, then attempts shortcut.
|
||||
var fastTask = TryFastUnlockWhenReadyAsync(pump, waitCts, ct);
|
||||
|
||||
// Wait for either: the full duration elapses, or the fast unlock succeeds
|
||||
// and cancels the wait CTS.
|
||||
await Task.WhenAll(progressTask, fastTask);
|
||||
|
||||
// If the outer ct was cancelled (user stop), propagate.
|
||||
ct.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
/// <summary>Reports progress every second until the wait token is cancelled.</summary>
|
||||
private async Task ReportProgressAsync(CancellationToken waitCt)
|
||||
{
|
||||
var start = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
while (!waitCt.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(1000, waitCt);
|
||||
var elapsed = DateTime.UtcNow - start;
|
||||
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
|
||||
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
|
||||
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the K-Line session to become Connected, then attempts the
|
||||
/// fast unlock. If the pump verifies unlocked afterward, cancels <paramref name="waitCts"/>
|
||||
/// to skip the remaining 600 s wait.
|
||||
/// </summary>
|
||||
private async Task TryFastUnlockWhenReadyAsync(
|
||||
PumpDefinition pump, CancellationTokenSource waitCts, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Wait for K-Line session to become Connected.
|
||||
if (_kwp.KLineState != KLineConnectionState.Connected)
|
||||
{
|
||||
_log.Info(LogId, "Waiting for K-Line session to connect...");
|
||||
var connectedTcs = new TaskCompletionSource<bool>();
|
||||
|
||||
void OnStateChanged(KLineConnectionState state)
|
||||
{
|
||||
if (state == KLineConnectionState.Connected)
|
||||
connectedTcs.TrySetResult(true);
|
||||
}
|
||||
|
||||
_kwp.KLineStateChanged += OnStateChanged;
|
||||
try
|
||||
{
|
||||
// Check again after subscribing (race guard).
|
||||
if (_kwp.KLineState == KLineConnectionState.Connected)
|
||||
connectedTcs.TrySetResult(true);
|
||||
|
||||
// Wait for connection or cancellation (user cancel or 600 s elapsed).
|
||||
using var reg = ct.Register(() => connectedTcs.TrySetCanceled());
|
||||
using var waitReg = waitCts.Token.Register(() => connectedTcs.TrySetCanceled());
|
||||
await connectedTcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_kwp.KLineStateChanged -= OnStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
// K-Line is now connected. Check if the pump is still locked.
|
||||
if (VerifyUnlock(pump))
|
||||
{
|
||||
_log.Info(LogId, "Pump already unlocked — skipping wait");
|
||||
waitCts.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pump is locked — attempt the fast K-Line unlock (RAM timer shortcut).
|
||||
_log.Info(LogId, "Attempting K-Line fast unlock (timer shortcut)...");
|
||||
StatusChanged?.Invoke("Fast unlock attempt...");
|
||||
|
||||
bool ack = await _kwp.TryFastUnlockAsync();
|
||||
if (!ack)
|
||||
{
|
||||
_log.Info(LogId, "Fast unlock NAK or failed — continuing normal wait");
|
||||
StatusChanged?.Invoke("Unlocking...");
|
||||
return;
|
||||
}
|
||||
|
||||
_log.Info(LogId, "Fast unlock ACK — waiting briefly for pump to process");
|
||||
|
||||
// Give the pump a moment to process the timer shortcut, then verify.
|
||||
await Task.Delay(2000, ct);
|
||||
|
||||
if (VerifyUnlock(pump))
|
||||
{
|
||||
_log.Info(LogId, "Fast unlock verified — skipping remaining wait");
|
||||
waitCts.Cancel();
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Info(LogId, "Fast unlock ACK'd but pump still locked — continuing normal wait");
|
||||
StatusChanged?.Invoke("Unlocking...");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Wait elapsed or user cancelled — fast unlock window closed, that's fine.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(LogId, $"Fast unlock attempt error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: TestUnlock state machine ────────────────────────────────────
|
||||
|
||||
private void RunTestUnlockSequence(int unlockType)
|
||||
{
|
||||
// The state machine cycles through 4 command bytes, twice.
|
||||
byte[][] type1Cmds =
|
||||
{
|
||||
new byte[] { 0xB2, 0, 0, 0, 0, 0, 0, 0 },
|
||||
@@ -188,8 +333,9 @@ namespace HC_APTBS.Services.Impl
|
||||
switch (pump.UnlockType)
|
||||
{
|
||||
case 1:
|
||||
// Type 1: unlocked when TestUnlock value is non-zero.
|
||||
return unlockParam.Value != 0;
|
||||
// Type 1: unlocked when TestUnlock value is zero.
|
||||
// Old code: Lock = valor != 0 (non-zero = locked).
|
||||
return unlockParam.Value == 0;
|
||||
case 2:
|
||||
// Type 2: unlocked when TestUnlock value equals 0xE4 (228).
|
||||
return (int)unlockParam.Value == 0xE4;
|
||||
|
||||
Reference in New Issue
Block a user