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

@@ -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
}
}

View File

@@ -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);

View File

@@ -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>

View 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;
}
}

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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();

View File

@@ -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/>

View 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";
}
}

View File

@@ -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);
}

View File

@@ -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;