feat: developer tools page, auto-test orchestrator, BIP display, tests redesign

Bundles several feature streams that have been iterating on the working tree:

- Developer Tools page (Debug-only via DEVELOPER_TOOLS symbol): hosts the
  identification card, manual KWP write + transaction log, ROM/EEPROM dump
  card with progress banner and completion message, persisted custom-commands
  library, persisted EEPROM passwords library. New service primitives:
  IKwpService.SendRawCustomAsync / ReadEepromAsync / ReadRomEepromAsync.
  Persistence mirrors the Clients XML pattern in two new files
  (custom_commands.xml, eeprom_passwords.xml).
- Auto-test orchestrator (IAutoTestOrchestrator + AutoTestState): linear
  K-Line read -> unlock -> bench-on -> test sequence with snackbar UI and
  progress dialog VM, gated on dashboard alarms.
- BIP-STATUS display: BipDisplayViewModel + BipDisplayView, RAM read at
  0x0106 via IKwpService.ReadBipStatusAsync; status definitions in
  BipStatusDefinition.
- Tests page redesign: TestSectionCard + PhaseTileView replacing the old
  TestPlanView/TestRunningView/TestDoneView/TestPreconditionsView/
  TestSectionView controls and their VMs.
- Pump command sliders: Fluent thick-track style with overhang thumb,
  click-anywhere-and-drag, mouse-wheel adjustment.
- Window startup: app.manifest declares PerMonitorV2 DPI awareness,
  MainWindow installs a WM_GETMINMAXINFO hook in OnSourceInitialized and
  maximizes there (after the hook is in place) so the app fits the work
  area exactly on any display configuration.
- Misc: PercentToPixelsConverter, seed_aliases.py one-shot pump-alias
  importer, tools/Import-BipStatus.ps1, kline_eeprom_spec.md and
  dump-functions reference docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 13:59:50 +02:00
parent da0581967b
commit 827b811b39
102 changed files with 7522 additions and 1798 deletions

117
Services/AutoTestState.cs Normal file
View File

@@ -0,0 +1,117 @@
namespace HC_APTBS.Services
{
/// <summary>
/// Phases of the Dashboard "Connect &amp; Auto Test" orchestration.
/// Drives the inline snackbar label and the orchestrator's internal branching.
/// </summary>
public enum AutoTestState
{
/// <summary>No auto-test sequence is active.</summary>
Idle = 0,
/// <summary>Pre-flight gate before any hardware action (e.g. operator confirmation).</summary>
Preflight,
/// <summary>Opening the K-Line session over FTDI.</summary>
ConnectingKLine,
/// <summary>Reading pump identification/DFI/serial over K-Line.</summary>
ReadingPump,
/// <summary>Running the immobilizer unlock (Ford VP44 Types 1 and 2).</summary>
Unlocking,
/// <summary>Energising the electronic relay and starting CAN senders.</summary>
TurningOnBench,
/// <summary>Energising the oil-pump relay.</summary>
StartingOilPump,
/// <summary>Kicking off <c>RunTestsAsync</c>.</summary>
StartingTest,
/// <summary>Test sequence executing; phase/sample updates from BenchService.</summary>
Running,
/// <summary>Sequence finished successfully.</summary>
Completed,
/// <summary>Sequence aborted; <see cref="AutoTestFailureReason"/> carries the cause.</summary>
Aborted,
}
/// <summary>
/// Reason an auto-test sequence aborted, for snackbar messaging and logging.
/// </summary>
public enum AutoTestFailureReason
{
/// <summary>No failure (default).</summary>
None = 0,
/// <summary>Operator cancelled from the snackbar or dismissed the pre-flight dialog.</summary>
UserCancelled,
/// <summary>CanExecute gate failed after click (alarm appeared between render and execute).</summary>
PreflightDenied,
/// <summary>FTDI port not found or <see cref="IKwpService.ConnectAsync"/> threw.</summary>
KLineConnectFailed,
/// <summary>K-Line session dropped to Failed state mid-sequence.</summary>
KLineLost,
/// <summary>Pump identification read failed (exception or result=0).</summary>
ReadFailed,
/// <summary>K-Line read completed but the pump ID is not in <c>pumps.xml</c>.</summary>
PumpNotRecognized,
/// <summary>Unlock sequence failed or verification returned locked.</summary>
UnlockFailed,
/// <summary>Bench CAN liveness dropped mid-sequence.</summary>
BenchCanLost,
/// <summary>Pump ECU CAN liveness dropped mid-sequence.</summary>
PumpCanLost,
/// <summary>A critical alarm bit transitioned to active during the sequence.</summary>
AlarmTriggered,
/// <summary>Operator has not enabled AutoTestSkipsOilPumpConfirm and the auto flow cannot proceed.</summary>
OilPumpNotConfirmed,
/// <summary>BenchService.RunTestsAsync signalled <c>interrupted=true</c>.</summary>
TestInterrupted,
/// <summary>BenchService.RunTestsAsync completed with <c>success=false</c>.</summary>
TestFailed,
/// <summary>Unexpected exception not covered by the categories above.</summary>
Unexpected,
}
/// <summary>Extension helpers for <see cref="AutoTestState"/>.</summary>
public static class AutoTestStateExtensions
{
/// <summary>
/// True for all intermediate phases (not Idle / Completed / Aborted).
/// Used by MainViewModel.CanAutoTest to block re-entry and to pick the button's
/// "Cancel" variant.
/// </summary>
public static bool IsRunning(this AutoTestState state) =>
state != AutoTestState.Idle &&
state != AutoTestState.Completed &&
state != AutoTestState.Aborted;
/// <summary>
/// True once the bench has been energised, i.e. after a failure the auto-flow
/// must request an emergency stop (not just disconnect).
/// </summary>
public static bool IsPastBenchOn(this AutoTestState state) =>
state == AutoTestState.TurningOnBench ||
state == AutoTestState.StartingOilPump ||
state == AutoTestState.StartingTest ||
state == AutoTestState.Running;
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Models;
namespace HC_APTBS.Services
{
/// <summary>
/// Minimal host-side contract supplying runtime context that the
/// <see cref="IAutoTestOrchestrator"/> cannot derive on its own
/// (e.g. <see cref="CurrentPump"/>, which is owned by a ViewModel).
/// Implemented by <c>MainViewModel</c>.
/// </summary>
public interface IAutoTestHost
{
/// <summary>Currently selected pump, if any.</summary>
PumpDefinition? CurrentPump { get; }
/// <summary>
/// Ensures the oil-pump relay is energised before the auto-test proceeds.
/// <list type="bullet">
/// <item>Returns <c>true</c> immediately if the pump is already on.</item>
/// <item>When <paramref name="skipConfirmation"/> is <c>true</c>, silently
/// starts the pump and returns <c>true</c>.</item>
/// <item>Otherwise, presents the leak-check confirmation dialog on the UI
/// thread. Returns <c>true</c> if the operator accepts (and the pump
/// is started), <c>false</c> if the operator cancels.</item>
/// </list>
/// Safe to call from the orchestrator's background execution — the host
/// marshals to the UI thread internally.
/// </summary>
Task<bool> EnsureOilPumpOnAsync(bool skipConfirmation);
}
/// <summary>
/// Orchestrates the Dashboard "Connect &amp; Auto Test" sequence.
///
/// <para>Drives a linear state machine (<see cref="AutoTestState"/>) that
/// connects the K-Line, reads the pump identification, runs the immobilizer
/// unlock (if required), energises the bench, starts the oil pump, and then
/// launches <see cref="IBenchService.RunTestsAsync"/>. Monitors liveness and
/// alarm transitions throughout so the sequence aborts safely on any failure,
/// requesting an emergency stop once the bench is energised.</para>
/// </summary>
public interface IAutoTestOrchestrator
{
/// <summary>Current state of the sequence.</summary>
AutoTestState State { get; }
/// <summary>
/// Raised on every state transition. The optional <c>detail</c> argument
/// carries phase-specific information (progress percent, phase name, etc.).
/// Marshalling to the UI thread is the subscriber's responsibility.
/// </summary>
event Action<AutoTestState, string?>? StateChanged;
/// <summary>
/// Raised once when the sequence aborts, before <see cref="State"/> transitions
/// to <see cref="AutoTestState.Aborted"/>.
/// </summary>
event Action<AutoTestFailureReason, string>? Failed;
/// <summary>
/// Runs the full auto-test sequence. Returns <c>true</c> on success,
/// <c>false</c> on any abort (failure reason is delivered via <see cref="Failed"/>).
/// </summary>
Task<bool> RunAsync(CancellationToken ct);
/// <summary>
/// Requests a cooperative cancellation of the in-flight sequence.
/// Safe to call when <see cref="State"/> is <see cref="AutoTestState.Idle"/>.
/// </summary>
void Cancel();
}
}

View File

@@ -43,6 +43,28 @@ namespace HC_APTBS.Services
/// <summary>Persists a pump definition back to the database.</summary>
void SavePump(PumpDefinition pump);
// ── Pump equivalence / alias lookup ───────────────────────────────────────
/// <summary>
/// Returns the canonical pump ID whose <c>&lt;Aliases&gt;&lt;KlineId&gt;</c>
/// entries contain <paramref name="klinePumpId"/>, or <c>null</c> if no
/// alias matches. Comparison is case-insensitive and trims whitespace.
/// </summary>
string? FindPumpIdByKlineAlias(string klinePumpId);
/// <summary>
/// Returns the canonical pump ID whose <c>&lt;Aliases&gt;&lt;ModelRef&gt;</c>
/// entries equal <paramref name="modelRef"/> (case-insensitive, trimmed),
/// or <c>null</c> if no alias matches.
/// </summary>
string? FindPumpIdByModelRef(string modelRef);
/// <summary>
/// Persists a new K-Line alias under the given canonical pump.
/// No-op if the canonical pump does not exist or if the alias is already present.
/// </summary>
void AddKlineAlias(string canonicalPumpId, string klineAlias);
// ── Clients ───────────────────────────────────────────────────────────────
/// <summary>Sorted client name → contact info dictionary.</summary>
@@ -51,6 +73,26 @@ namespace HC_APTBS.Services
/// <summary>Persists the client list to <c>clients.xml</c>.</summary>
void SaveClients();
// ── Developer libraries (saved KWP commands + EEPROM passwords) ──────────
/// <summary>
/// User-saved raw KWP custom commands, persisted in <c>custom_commands.xml</c>.
/// Populated and edited by the Developer Tools page; not used by production paths.
/// </summary>
ObservableCollection<CustomCommand> CustomCommands { get; }
/// <summary>Persists <see cref="CustomCommands"/> to <c>custom_commands.xml</c>.</summary>
void SaveCustomCommands();
/// <summary>
/// User-saved EEPROM unlock passwords (zone + 16-bit key), persisted in
/// <c>eeprom_passwords.xml</c>. Populated and edited by the Developer Tools page.
/// </summary>
ObservableCollection<EepromPassword> EepromPasswords { get; }
/// <summary>Persists <see cref="EepromPasswords"/> to <c>eeprom_passwords.xml</c>.</summary>
void SaveEepromPasswords();
// ── Pump status definitions ───────────────────────────────────────────────
/// <summary>

View File

@@ -136,11 +136,66 @@ namespace HC_APTBS.Services
/// <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.
/// over an existing K-Line session. The RAM address byte written by the
/// command is selected by <paramref name="unlockType"/>: <c>1</c> → <c>0xA8</c>,
/// <c>2</c> → <c>0xE8</c>. Any other value is rejected and returns
/// <see langword="false"/>.
/// </summary>
Task<bool> TryFastUnlockAsync();
/// <param name="unlockType">Pump unlock variant (1 or 2).</param>
/// <returns>
/// <see langword="true"/> when the command was acknowledged,
/// <see langword="false"/> on NAK, no active session, or unsupported type.
/// </returns>
Task<bool> TryFastUnlockAsync(int unlockType);
// ── Raw custom KWP packet (developer use) ─────────────────────────────────
/// <summary>
/// Sends a raw KWP1281 custom packet over the persistent K-Line session and
/// returns the bytes of every response packet. The supplied <paramref name="payload"/>
/// is the title byte plus body — framing (length + counter + end byte) is added
/// by the lower-level transport.
///
/// <para>Returns an empty list when no session is active or the send fails.
/// Used by the Developer Tools page; never called from production paths.</para>
/// </summary>
/// <param name="payload">Packet bytes excluding length/counter/end framing.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Each response packet's full byte sequence (length…end inclusive).</returns>
Task<IReadOnlyList<byte[]>> SendRawCustomAsync(byte[] payload, CancellationToken ct = default);
/// <summary>
/// Reads <paramref name="count"/> bytes from EEPROM starting at <paramref name="address"/>
/// over the persistent K-Line session (KWP <c>ReadEeprom</c> command 0x19).
/// Returns an empty list when no session is active or the ECU returns NAK.
/// Used by the Developer Tools page's dumper; max 13 bytes per call.
/// </summary>
Task<IReadOnlyList<byte>> ReadEepromAsync(ushort address, byte count, CancellationToken ct = default);
/// <summary>
/// Reads <paramref name="count"/> bytes from ROM/EEPROM starting at <paramref name="address"/>
/// over the persistent K-Line session (KWP <c>ReadRomEeprom</c> command 0x03).
/// Valid range 0x00000x9FFF. Returns an empty list when no session is active or
/// the ECU returns NAK. Used by the Developer Tools page's dumper; max 13 bytes per call.
/// </summary>
Task<IReadOnlyList<byte>> ReadRomEepromAsync(ushort address, byte count, CancellationToken ct = default);
// ── BIP status ────────────────────────────────────────────────────────────
/// <summary>
/// Reads the 16-bit BIP status word from ECU RAM address <c>0x0106</c>
/// (<c>ADR-S_BIP_HW_UW</c>) over the persistent K-Line session.
/// Returns <see langword="null"/> when no session is active or if the read fails.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task<ushort?> ReadBipStatusAsync(CancellationToken ct = default);
/// <summary>
/// Raised after a successful <see cref="ReadBipStatusAsync"/> call with the
/// raw 16-bit BIP status word. Fires on a background thread;
/// consumers must marshal to the UI thread.
/// </summary>
event Action<ushort>? BipStatusRead;
// ── Power cycle callbacks ─────────────────────────────────────────────────

View File

@@ -26,6 +26,15 @@ namespace HC_APTBS.Services
/// </summary>
event Action? PumpUnlocked;
/// <summary>
/// Raised by the background observer on each unlock→lock transition —
/// the symmetric counterpart to <see cref="PumpUnlocked"/>. Fires whenever
/// the CAN TestUnlock parameter transitions from an unlocked state to a
/// locked state (e.g. physical pump swap, power instability). Subscribers
/// must marshal to the UI thread themselves.
/// </summary>
event Action? PumpRelocked;
/// <summary>
/// Latched state from the background observer. True when the observer has
/// verified the pump is unlocked; false when the observer is not running

View File

@@ -0,0 +1,516 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Models;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Linear state-machine orchestrator for the Dashboard "Connect &amp; Auto Test"
/// button. Coordinates existing services (<see cref="IKwpService"/>,
/// <see cref="ICanService"/>, <see cref="IBenchService"/>, <see cref="IUnlockService"/>,
/// <see cref="IConfigurationService"/>) rather than re-implementing any protocol.
///
/// <para>Behavioural contract:</para>
/// <list type="bullet">
/// <item>Linear phases: <see cref="AutoTestState.Preflight"/> →
/// <see cref="AutoTestState.ConnectingKLine"/> →
/// <see cref="AutoTestState.ReadingPump"/> →
/// <see cref="AutoTestState.Unlocking"/> →
/// <see cref="AutoTestState.TurningOnBench"/> →
/// <see cref="AutoTestState.StartingOilPump"/> →
/// <see cref="AutoTestState.StartingTest"/> →
/// <see cref="AutoTestState.Running"/> →
/// <see cref="AutoTestState.Completed"/> or <see cref="AutoTestState.Aborted"/>.</item>
/// <item>Connecting/Reading are skipped when the K-Line is already open and a pump
/// is already selected (fast-path for "re-run on the same pump").</item>
/// <item>Unlocking is skipped when the selected pump's
/// <see cref="PumpDefinition.UnlockType"/> is 0.</item>
/// <item>When the oil-pump leak-check confirmation has not been disabled via
/// <see cref="AppSettings.AutoTestSkipsOilPumpConfirm"/>, the sequence aborts
/// with <see cref="AutoTestFailureReason.OilPumpNotConfirmed"/> before the
/// relay is energised.</item>
/// <item>Failure past <see cref="AutoTestStateExtensions.IsPastBenchOn(AutoTestState)"/>
/// triggers <see cref="IBenchService.RequestEmergencyStop"/>; earlier failures
/// close the K-Line and exit cleanly.</item>
/// </list>
/// </summary>
public sealed class AutoTestOrchestrator : IAutoTestOrchestrator
{
private readonly IKwpService _kwp;
private readonly ICanService _can;
private readonly IBenchService _bench;
private readonly IUnlockService _unlock;
private readonly IConfigurationService _config;
private readonly IAppLogger _log;
private readonly Func<IAutoTestHost> _hostFactory;
private const string LogId = "AutoTestOrch";
private CancellationTokenSource? _autoCts;
private AutoTestState _state = AutoTestState.Idle;
/// <summary>Latest test-phase name observed from <see cref="IBenchService.PhaseChanged"/>.</summary>
private string? _latestPhaseDetail;
/// <summary>Raised once a failure has been reported; guards against duplicate emits.</summary>
private bool _failureReported;
/// <inheritdoc/>
public AutoTestState State => _state;
/// <inheritdoc/>
public event Action<AutoTestState, string?>? StateChanged;
/// <inheritdoc/>
public event Action<AutoTestFailureReason, string>? Failed;
/// <summary>
/// Creates an orchestrator wired to the core services. The <paramref name="hostFactory"/>
/// resolves the <see cref="IAutoTestHost"/> lazily so that the orchestrator can be
/// constructed by the DI container at the same time as <c>MainViewModel</c> (which
/// implements <see cref="IAutoTestHost"/>) without creating a construction-order cycle.
/// </summary>
public AutoTestOrchestrator(
IKwpService kwp,
ICanService can,
IBenchService bench,
IUnlockService unlock,
IConfigurationService config,
IAppLogger log,
Func<IAutoTestHost> hostFactory)
{
_kwp = kwp;
_can = can;
_bench = bench;
_unlock = unlock;
_config = config;
_log = log;
_hostFactory = hostFactory;
}
/// <inheritdoc/>
public void Cancel() => _autoCts?.Cancel();
/// <inheritdoc/>
public async Task<bool> RunAsync(CancellationToken ct)
{
if (_state.IsRunning())
{
_log.Warning(LogId, "RunAsync called while a sequence is already in progress");
return false;
}
_failureReported = false;
_latestPhaseDetail = null;
_autoCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var token = _autoCts.Token;
// ── Abort watchers ────────────────────────────────────────────────────
// Subscribe these up-front so any mid-sequence hardware drop-out trips
// the CTS immediately. Unsubscribed in the finally block.
void OnBenchLiveness(bool alive)
{
if (alive) return;
if (_state == AutoTestState.Idle || _state == AutoTestState.Preflight) return;
ReportFailure(AutoTestFailureReason.BenchCanLost, "Bench CAN liveness lost");
_autoCts?.Cancel();
}
void OnKLineState(KLineConnectionState st)
{
if (st != KLineConnectionState.Failed) return;
// Only treat as loss when K-Line is actually required by the current phase.
if (_state is AutoTestState.ConnectingKLine
or AutoTestState.ReadingPump
or AutoTestState.Unlocking)
{
ReportFailure(AutoTestFailureReason.KLineLost, "K-Line session dropped");
_autoCts?.Cancel();
}
}
// Snapshot the alarm mask on entry; any transition that flips a critical bit
// to "set" aborts the run. Uses the bench alarm parameter directly so we stay
// decoupled from DashboardAlarmsViewModel.
int initialMask = ReadAlarmMask();
int criticalMask = BuildCriticalAlarmBitmask();
_can.BenchLivenessChanged += OnBenchLiveness;
_kwp.KLineStateChanged += OnKLineState;
_bench.PhaseChanged += OnPhaseChanged;
using var alarmWatch = StartAlarmWatchdog(token, initialMask, criticalMask);
try
{
// ── Preflight ────────────────────────────────────────────────────
SetState(AutoTestState.Preflight);
if (!_can.IsConnected)
{
ReportFailure(AutoTestFailureReason.PreflightDenied, "CAN bus not connected");
return false;
}
if ((ReadAlarmMask() & criticalMask) != 0)
{
ReportFailure(AutoTestFailureReason.PreflightDenied, "Critical alarm already active");
return false;
}
token.ThrowIfCancellationRequested();
var host = _hostFactory();
bool klineAlreadyOpen =
_kwp.KLineState == KLineConnectionState.Connected &&
host.CurrentPump != null;
// ── ConnectingKLine ──────────────────────────────────────────────
if (!klineAlreadyOpen)
{
SetState(AutoTestState.ConnectingKLine);
string? port = _kwp.DetectKLinePort();
if (string.IsNullOrEmpty(port))
{
ReportFailure(AutoTestFailureReason.KLineConnectFailed,
"FTDI adapter not found");
return false;
}
// ConnectAsync opens the session and starts the keep-alive loop.
// If the session is already open the service returns immediately.
try
{
await _kwp.ConnectAsync(port, token).ConfigureAwait(true);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
ReportFailure(AutoTestFailureReason.KLineConnectFailed,
$"ConnectAsync failed: {ex.Message}");
return false;
}
}
// ── ReadingPump ──────────────────────────────────────────────────
if (!klineAlreadyOpen)
{
SetState(AutoTestState.ReadingPump);
int version = host.CurrentPump?.KwpVersion ?? 0;
string? port = _kwp.ConnectedPort ?? _kwp.DetectKLinePort();
if (string.IsNullOrEmpty(port))
{
ReportFailure(AutoTestFailureReason.KLineConnectFailed,
"FTDI adapter disappeared before read");
return false;
}
// Forward ReadAllInfoAsync percentage ticks to the snackbar.
void OnProgress(int pct, string _) =>
RaiseStateChanged(AutoTestState.ReadingPump, pct.ToString());
_kwp.ProgressChanged += OnProgress;
System.Collections.Generic.Dictionary<string, string> info;
try
{
info = await _kwp.ReadAllInfoAsync(port, version, token).ConfigureAwait(true);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
ReportFailure(AutoTestFailureReason.ReadFailed,
$"ReadAllInfoAsync threw: {ex.Message}");
return false;
}
finally
{
_kwp.ProgressChanged -= OnProgress;
}
if (!info.TryGetValue(KlineKeys.Result, out var result) || result != "1")
{
ReportFailure(AutoTestFailureReason.ReadFailed,
info.TryGetValue(KlineKeys.ConnectError, out var err) ? err : "Read result=0");
return false;
}
// The KwpService.PumpIdentified event fires mid-read on the background
// thread and is marshalled to the UI thread by PumpIdentificationViewModel,
// which sets SelectedPumpId → CurrentPump via a synchronous side-effect
// chain. After await completes, CurrentPump is therefore populated —
// unless the K-Line pump ID was not in pumps.xml.
if (host.CurrentPump == null)
{
ReportFailure(AutoTestFailureReason.PumpNotRecognized,
info.TryGetValue(KlineKeys.PumpId, out var kid)
? $"Pump ID '{kid}' not in database"
: "Pump ID not recognised");
return false;
}
}
var pump = host.CurrentPump;
if (pump == null)
{
ReportFailure(AutoTestFailureReason.PumpNotRecognized, "No pump selected");
return false;
}
token.ThrowIfCancellationRequested();
// ── Unlocking ────────────────────────────────────────────────────
// When the pump was auto-selected during the read, MainViewModel.OnPumpChanged
// already started UnlockService.UnlockAsync AND the 1 s observer in the
// background. We wait on whichever fires first:
// - PumpUnlocked (observer confirmed the CAN TestUnlock param flipped),
// - UnlockCompleted (the service's own UnlockAsync finished).
// The observer race-guards against the case where the pump auto-unlocks
// (fast unlock shortcut or an external manual unlock) before we subscribe.
if (pump.UnlockType != 0)
{
SetState(AutoTestState.Unlocking);
// Race-guard short-circuit: if the observer already latched an
// unlocked state (fast unlock finished while we were still doing
// the K-Line read), skip straight past the Unlocking wait.
if (_unlock.IsPumpUnlocked)
{
RaiseStateChanged(AutoTestState.Unlocking, "Pump already unlocked");
}
else
{
var unlockTcs = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnUnlockStatus(string msg) =>
RaiseStateChanged(AutoTestState.Unlocking, msg);
void OnUnlockCompleted(bool success) => unlockTcs.TrySetResult(success);
// Observer fires as soon as the CAN TestUnlock parameter reports
// unlocked — this covers fast unlock and external unlocks that
// would otherwise only be observed when UnlockAsync itself finishes.
void OnPumpUnlocked() => unlockTcs.TrySetResult(true);
_unlock.StatusChanged += OnUnlockStatus;
_unlock.UnlockCompleted += OnUnlockCompleted;
_unlock.PumpUnlocked += OnPumpUnlocked;
using var ctReg = token.Register(() => unlockTcs.TrySetCanceled());
bool unlocked;
try
{
// Re-check after subscribing to close the subscribe-vs-fire race.
if (_unlock.IsPumpUnlocked)
unlockTcs.TrySetResult(true);
unlocked = await unlockTcs.Task.ConfigureAwait(true);
}
finally
{
_unlock.StatusChanged -= OnUnlockStatus;
_unlock.UnlockCompleted -= OnUnlockCompleted;
_unlock.PumpUnlocked -= OnPumpUnlocked;
}
if (!unlocked)
{
ReportFailure(AutoTestFailureReason.UnlockFailed, "Unlock verification failed");
return false;
}
}
}
token.ThrowIfCancellationRequested();
// ── TurningOnBench ───────────────────────────────────────────────
// Past this point any failure must request an emergency stop.
SetState(AutoTestState.TurningOnBench);
_bench.SetRelay(RelayNames.Electronic, true);
_bench.SetRpm(0);
token.ThrowIfCancellationRequested();
// ── StartingOilPump ──────────────────────────────────────────────
// Delegate to the UI host: handles already-on short-circuit, the
// autoskip setting, and the leak-check dialog. Returns false only
// when the operator actively cancels the confirmation dialog.
SetState(AutoTestState.StartingOilPump);
bool oilPumpReady = await _hostFactory()
.EnsureOilPumpOnAsync(_config.Settings.AutoTestSkipsOilPumpConfirm)
.ConfigureAwait(true);
if (!oilPumpReady)
{
ReportFailure(AutoTestFailureReason.OilPumpNotConfirmed,
string.Empty);
return false;
}
token.ThrowIfCancellationRequested();
// ── StartingTest / Running ───────────────────────────────────────
SetState(AutoTestState.StartingTest);
var testTcs = new TaskCompletionSource<(bool interrupted, bool success)>(
TaskCreationOptions.RunContinuationsAsynchronously);
void OnTestStarted() => SetState(AutoTestState.Running, _latestPhaseDetail);
void OnTestFinished(bool interrupted, bool success)
=> testTcs.TrySetResult((interrupted, success));
void OnVerbose(string msg) => RaiseStateChanged(AutoTestState.Running, msg);
_bench.TestStarted += OnTestStarted;
_bench.TestFinished += OnTestFinished;
_bench.VerboseMessage += OnVerbose;
try
{
// RunTestsAsync runs its sequence on a background task internally;
// we wait on TestFinished so we observe success/interruption state.
await _bench.RunTestsAsync(pump, token).ConfigureAwait(true);
// RunTestsAsync returns once the background task completes, but the
// TestFinished event is the authoritative source for interrupted/success.
var result = await testTcs.Task.ConfigureAwait(true);
if (result.interrupted)
{
ReportFailure(AutoTestFailureReason.TestInterrupted, "Test interrupted");
return false;
}
if (!result.success)
{
ReportFailure(AutoTestFailureReason.TestFailed, "Test failed");
return false;
}
}
finally
{
_bench.TestStarted -= OnTestStarted;
_bench.TestFinished -= OnTestFinished;
_bench.VerboseMessage -= OnVerbose;
}
SetState(AutoTestState.Completed);
return true;
}
catch (OperationCanceledException)
{
if (!_failureReported)
ReportFailure(AutoTestFailureReason.UserCancelled, "Cancelled");
return false;
}
catch (Exception ex)
{
_log.Error(LogId, $"Unexpected exception: {ex}");
if (!_failureReported)
ReportFailure(AutoTestFailureReason.Unexpected, ex.Message);
return false;
}
finally
{
_can.BenchLivenessChanged -= OnBenchLiveness;
_kwp.KLineStateChanged -= OnKLineState;
_bench.PhaseChanged -= OnPhaseChanged;
// E-stop only if we failed past bench-on.
if (_state.IsPastBenchOn() && _state != AutoTestState.Completed)
{
try { _bench.RequestEmergencyStop("Auto-test aborted"); }
catch (Exception ex) { _log.Error(LogId, $"E-stop failed: {ex.Message}"); }
}
if (_state != AutoTestState.Completed && _state != AutoTestState.Aborted)
SetState(AutoTestState.Aborted);
_autoCts?.Dispose();
_autoCts = null;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private void SetState(AutoTestState next, string? detail = null)
{
_state = next;
RaiseStateChanged(next, detail);
}
private void RaiseStateChanged(AutoTestState s, string? detail)
=> StateChanged?.Invoke(s, detail);
private void ReportFailure(AutoTestFailureReason reason, string message)
{
if (_failureReported) return;
_failureReported = true;
_log.Warning(LogId, $"Failed: {reason} — {message}");
Failed?.Invoke(reason, message);
}
private void OnPhaseChanged(string phaseName)
{
_latestPhaseDetail = phaseName;
if (_state == AutoTestState.Running)
RaiseStateChanged(AutoTestState.Running, phaseName);
}
private int ReadAlarmMask()
{
try { return (int)_bench.ReadBenchParameter(BenchParameterNames.Alarms); }
catch { return 0; }
}
private int BuildCriticalAlarmBitmask()
{
int mask = 0;
foreach (var a in _config.Settings.Alarms)
if (a.IsCritical) mask |= 1 << a.Bit;
return mask;
}
/// <summary>
/// Starts a lightweight task that polls the bench alarm word and aborts the
/// run if any critical bit transitions from clear to set.
/// </summary>
private IDisposable StartAlarmWatchdog(CancellationToken token, int initialMask, int criticalMask)
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
int lastMask = initialMask;
_ = Task.Run(async () =>
{
try
{
while (!cts.IsCancellationRequested)
{
await Task.Delay(250, cts.Token).ConfigureAwait(false);
int now = ReadAlarmMask();
int newlySet = now & ~lastMask;
lastMask = now;
if ((newlySet & criticalMask) != 0)
{
ReportFailure(AutoTestFailureReason.AlarmTriggered,
"Critical alarm transitioned active");
_autoCts?.Cancel();
return;
}
}
}
catch (OperationCanceledException) { /* expected */ }
}, cts.Token);
return new Disposer(cts);
}
private sealed class Disposer : IDisposable
{
private readonly CancellationTokenSource _cts;
public Disposer(CancellationTokenSource cts) { _cts = cts; }
public void Dispose()
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -22,13 +23,15 @@ namespace HC_APTBS.Services.Impl
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".HC_APTBS", "config");
private string ConfigXml => Path.Combine(ConfigFolder, "config.xml");
private string PumpsXml => Path.Combine(ConfigFolder, "pumps.xml");
private string BenchXml => Path.Combine(ConfigFolder, "bench.xml");
private string SensorsXml => Path.Combine(ConfigFolder, "sensors.xml");
private string ClientsXml => Path.Combine(ConfigFolder, "clients.xml");
private string AlarmsXml => Path.Combine(ConfigFolder, "alarms.xml");
private string StatusXml => Path.Combine(ConfigFolder, "status.xml");
private string ConfigXml => Path.Combine(ConfigFolder, "config.xml");
private string PumpsXml => Path.Combine(ConfigFolder, "pumps.xml");
private string BenchXml => Path.Combine(ConfigFolder, "bench.xml");
private string SensorsXml => Path.Combine(ConfigFolder, "sensors.xml");
private string ClientsXml => Path.Combine(ConfigFolder, "clients.xml");
private string AlarmsXml => Path.Combine(ConfigFolder, "alarms.xml");
private string StatusXml => Path.Combine(ConfigFolder, "status.xml");
private string CustomCommandsXml => Path.Combine(ConfigFolder, "custom_commands.xml");
private string EepromPasswordsXml => Path.Combine(ConfigFolder, "eeprom_passwords.xml");
private readonly IAppLogger _log;
private const string LogId = "ConfigurationService";
@@ -38,7 +41,21 @@ namespace HC_APTBS.Services.Impl
private AppSettings? _settings;
private BenchConfiguration? _bench;
private SortedDictionary<string, string>? _clients;
private ObservableCollection<CustomCommand>? _customCommands;
private ObservableCollection<EepromPassword>? _eepromPasswords;
// Keyed by StatusID; shared by both status.xml and the pumps.xml <Palabras> fallback.
// Two distinct pumps that happen to share the same StatusID integer get the same
// table — acceptable given that the 9 known tables are pump-family-shared (same
// design as the legacy runtime). Future per-pump-override seam: key on (pumpId, statusId).
private readonly Dictionary<int, PumpStatusDefinition> _statusCache = new();
// Lazily populated on first fallback lookup; null until first pumps.xml parse attempt.
private Dictionary<int, PumpStatusDefinition>? _palabrasStatusCache;
// Alias indexes: K-Line pumpID alias -> canonical pump ID, and ModelRef alias -> canonical pump ID.
// Built once from <Aliases> blocks across all <Pump> elements; both null until first lookup.
// Invalidated (set to null) whenever an alias or pump is persisted.
private Dictionary<string, string>? _klineAliasIndex;
private Dictionary<string, string>? _modelRefAliasIndex;
// ── Constructor ───────────────────────────────────────────────────────────
@@ -89,6 +106,7 @@ namespace HC_APTBS.Services.Impl
new XElement("MaxRpm", Settings.MaxRpm),
new XElement("RightRelayValue", Settings.RightRelayValue),
new XElement("DefaultIgnoreTin", Settings.DefaultIgnoreTin),
new XElement("AutoTestSkipsOilPumpConfirm", Settings.AutoTestSkipsOilPumpConfirm),
new XElement("LastRotationDir", Settings.LastRotationDirection),
new XElement("DaysKeepLogs", Settings.DaysKeepLogs),
new XElement("CompanyName", Settings.CompanyName),
@@ -229,6 +247,17 @@ namespace HC_APTBS.Services.Impl
// PumpID child element — GetPumpIds() finds these via Descendants("PumpID").
xpump.Add(new XElement("PumpID", pump.Id));
// ── Serialise <Aliases> (equivalence detection) ──
if (pump.KlineAliases.Count > 0 || pump.ModelRefAliases.Count > 0)
{
var xaliasesOut = new XElement("Aliases");
foreach (var a in pump.KlineAliases)
xaliasesOut.Add(new XElement("KlineId", a));
foreach (var a in pump.ModelRefAliases)
xaliasesOut.Add(new XElement("ModelRef", a));
xpump.Add(xaliasesOut);
}
// ── Serialise <Params> (pump CAN params use legacy P1P6 format) ──
if (pump.ParametersByName.Count > 0)
{
@@ -255,9 +284,9 @@ namespace HC_APTBS.Services.Impl
{
var b = pump.BipStatus.Bits[i];
xbip.Add(new XElement("Bit",
new XAttribute("index", i.ToString(CultureInfo.InvariantCulture)),
new XAttribute("index", b.Index.ToString(CultureInfo.InvariantCulture)),
new XAttribute("enabled", b.Enabled.ToString().ToLowerInvariant()),
new XAttribute("pattern", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
new XAttribute("hex", "0x" + b.HexPattern.ToString("X4", CultureInfo.InvariantCulture)),
new XAttribute("reaction", b.Reaction.ToString(CultureInfo.InvariantCulture)),
new XAttribute("specialFunction", b.SpecialFunction.ToString(CultureInfo.InvariantCulture)),
b.Description));
@@ -282,6 +311,7 @@ namespace HC_APTBS.Services.Impl
pumpsNode.Add(xpump);
xdoc.Save(PumpsXml);
InvalidateAliasIndexes();
_log.Info(LogId, $"SavePump({pump.Id}) — saved to pumps.xml.");
}
catch (Exception ex)
@@ -290,6 +320,149 @@ namespace HC_APTBS.Services.Impl
}
}
// ── IConfigurationService: Pump equivalence / alias lookup ────────────────
/// <inheritdoc/>
public string? FindPumpIdByKlineAlias(string klinePumpId)
{
if (string.IsNullOrWhiteSpace(klinePumpId)) return null;
EnsureAliasIndexes();
return _klineAliasIndex!.TryGetValue(klinePumpId.Trim(), out var canonical)
? canonical : null;
}
/// <inheritdoc/>
public string? FindPumpIdByModelRef(string modelRef)
{
if (string.IsNullOrWhiteSpace(modelRef)) return null;
EnsureAliasIndexes();
return _modelRefAliasIndex!.TryGetValue(modelRef.Trim(), out var canonical)
? canonical : null;
}
/// <inheritdoc/>
public void AddKlineAlias(string canonicalPumpId, string klineAlias)
{
if (string.IsNullOrWhiteSpace(canonicalPumpId) || string.IsNullOrWhiteSpace(klineAlias))
return;
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null)
{
_log.Warning(LogId, $"AddKlineAlias: no pumps.xml/config.xml found.");
return;
}
try
{
var xdoc = XDocument.Load(source);
var xpump = xdoc.XPathSelectElement($"/Config/Pumps/Pump[@id='{canonicalPumpId}']");
if (xpump == null)
{
_log.Warning(LogId, $"AddKlineAlias: pump '{canonicalPumpId}' not found.");
return;
}
var trimmed = klineAlias.Trim();
var xaliases = xpump.Element("Aliases");
if (xaliases == null)
{
xaliases = new XElement("Aliases");
xpump.Add(xaliases);
}
else
{
foreach (var existing in xaliases.Elements("KlineId"))
{
if (string.Equals(existing.Value.Trim(), trimmed, StringComparison.OrdinalIgnoreCase))
return; // already present
}
}
xaliases.Add(new XElement("KlineId", trimmed));
xdoc.Save(source);
InvalidateAliasIndexes();
_log.Info(LogId, $"AddKlineAlias: '{trimmed}' -> '{canonicalPumpId}' persisted.");
}
catch (Exception ex)
{
_log.Error(LogId, $"AddKlineAlias({canonicalPumpId}, {klineAlias}) failed: {ex.Message}");
}
}
private void InvalidateAliasIndexes()
{
_klineAliasIndex = null;
_modelRefAliasIndex = null;
}
/// <summary>
/// Builds the K-Line and ModelRef alias indexes by scanning every <c>&lt;Pump&gt;</c>
/// element in pumps.xml/config.xml for an <c>&lt;Aliases&gt;</c> block. Cheap enough
/// for a one-shot scan: dozens of pumps, a handful of aliases each.
/// </summary>
private void EnsureAliasIndexes()
{
if (_klineAliasIndex != null && _modelRefAliasIndex != null) return;
var klineMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var modelRefMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null)
{
_klineAliasIndex = klineMap;
_modelRefAliasIndex = modelRefMap;
return;
}
try
{
var xdoc = XDocument.Load(source);
foreach (var xpump in xdoc.Descendants("Pump"))
{
var canonical = xpump.Attribute("id")?.Value;
if (string.IsNullOrWhiteSpace(canonical)) continue;
var xaliases = xpump.Element("Aliases");
if (xaliases == null) continue;
foreach (var xkid in xaliases.Elements("KlineId"))
{
var alias = xkid.Value?.Trim();
if (string.IsNullOrEmpty(alias)) continue;
if (!klineMap.ContainsKey(alias))
klineMap[alias] = canonical;
else
_log.Warning(LogId,
$"Duplicate KlineId alias '{alias}' (already mapped to '{klineMap[alias]}', ignoring duplicate under '{canonical}').");
}
foreach (var xmref in xaliases.Elements("ModelRef"))
{
var alias = xmref.Value?.Trim();
if (string.IsNullOrEmpty(alias)) continue;
if (!modelRefMap.ContainsKey(alias))
modelRefMap[alias] = canonical;
else
_log.Warning(LogId,
$"Duplicate ModelRef alias '{alias}' (already mapped to '{modelRefMap[alias]}', ignoring duplicate under '{canonical}').");
}
}
}
catch (Exception ex)
{
_log.Error(LogId, $"EnsureAliasIndexes failed: {ex.Message}");
}
_klineAliasIndex = klineMap;
_modelRefAliasIndex = modelRefMap;
}
// ── IConfigurationService: Clients ────────────────────────────────────────
/// <inheritdoc/>
@@ -320,6 +493,65 @@ namespace HC_APTBS.Services.Impl
}
}
// ── IConfigurationService: Developer libraries ────────────────────────────
/// <inheritdoc/>
public ObservableCollection<CustomCommand> CustomCommands
{
get
{
if (_customCommands == null) LoadCustomCommands();
return _customCommands!;
}
}
/// <inheritdoc/>
public void SaveCustomCommands()
{
try
{
var root = new XElement("CustomCommands");
foreach (var cmd in CustomCommands)
root.Add(new XElement("command",
new XAttribute("name", cmd.Name ?? string.Empty),
new XAttribute("hex", cmd.HexBytes ?? string.Empty)));
new XDocument(root).Save(CustomCommandsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveCustomCommands failed: {ex.Message}");
}
}
/// <inheritdoc/>
public ObservableCollection<EepromPassword> EepromPasswords
{
get
{
if (_eepromPasswords == null) LoadEepromPasswords();
return _eepromPasswords!;
}
}
/// <inheritdoc/>
public void SaveEepromPasswords()
{
try
{
var root = new XElement("EepromPasswords");
foreach (var pw in EepromPasswords)
root.Add(new XElement("password",
new XAttribute("name", pw.Name ?? string.Empty),
new XAttribute("zone", pw.Zone.ToString("X2", CultureInfo.InvariantCulture)),
new XAttribute("key", pw.Key.ToString("X4", CultureInfo.InvariantCulture))));
new XDocument(root).Save(EepromPasswordsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveEepromPasswords failed: {ex.Message}");
}
}
// ── IConfigurationService: Sensors ────────────────────────────────────────
/// <inheritdoc/>
@@ -365,18 +597,33 @@ namespace HC_APTBS.Services.Impl
if (_statusCache.TryGetValue(statusId, out var cached))
return cached;
// Palabras (pumps.xml) is authoritative — matches legacy runtime which read
// /Config/Palabras/PumpStatus[@StatusID='N'] exclusively. status.xml is a
// new-system artifact and in practice carries stale/partial definitions that
// collide on shared StatusIDs (e.g. ID=5 labelled EMPF3 in status.xml but
// CAN-PSGTEST in palabras). Only fall back to status.xml when palabras is
// missing the id entirely.
var def = LoadPumpStatusFromPumpsXml(statusId);
if (def == null)
def = LoadPumpStatusFromXml(StatusXml, statusId);
if (def != null)
_statusCache[statusId] = def;
return def;
}
/// <summary>Parses a <c>&lt;PumpStatus StatusID="N"&gt;</c> element from any XML file.</summary>
private PumpStatusDefinition? LoadPumpStatusFromXml(string xmlPath, int statusId)
{
try
{
if (!File.Exists(StatusXml))
{
_log.Error(LogId, $"LoadPumpStatus: {StatusXml} not found.");
return null;
}
if (!File.Exists(xmlPath)) return null;
var xdoc = XDocument.Load(StatusXml);
var xdoc = XDocument.Load(xmlPath);
if (xdoc.Root == null) return null;
// Search for <PumpStatus StatusID="N"> in the document.
XElement? xStatus = null;
foreach (var el in xdoc.Root.Descendants("PumpStatus"))
{
@@ -387,52 +634,107 @@ namespace HC_APTBS.Services.Impl
}
}
if (xStatus == null)
{
_log.Error(LogId, $"LoadPumpStatus: StatusID={statusId} not found in status.xml.");
return null;
}
var def = new PumpStatusDefinition
{
Id = statusId,
Name = xStatus.Attribute("name")?.Value ?? "-"
};
foreach (var xState in xStatus.Elements("State"))
{
var bit = new StatusBit
{
Bit = int.Parse(xState.Attribute("bit")?.Value ?? "0"),
Enabled = string.Equals(
xState.Attribute("enabled")?.Value, "true",
StringComparison.OrdinalIgnoreCase)
};
foreach (var xVal in xState.Elements("StateValue"))
{
bit.Values.Add(new StatusBitValue
{
State = int.Parse(xVal.Attribute("value")?.Value ?? "0", CultureInfo.InvariantCulture),
Color = xVal.Attribute("color")?.Value ?? "26C200",
Description = xVal.Value.Trim(),
Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
});
}
def.Bits.Add(bit);
}
_statusCache[statusId] = def;
return def;
if (xStatus == null) return null;
return ParsePumpStatusElement(xStatus, statusId);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatus({statusId}) failed: {ex.Message}");
_log.Error(LogId, $"LoadPumpStatusFromXml({xmlPath}, {statusId}) failed: {ex.Message}");
return null;
}
}
/// <summary>
/// Lazily loads all <c>&lt;PumpStatus&gt;</c> entries from the <c>&lt;Palabras&gt;</c>
/// block in pumps.xml and returns the one matching <paramref name="statusId"/>.
/// The orphan block lives outside <c>&lt;/Pumps&gt;</c> and is not touched by the
/// pump loader — this is the only path that reads it.
/// </summary>
private PumpStatusDefinition? LoadPumpStatusFromPumpsXml(int statusId)
{
// Build the cache on first call.
if (_palabrasStatusCache == null)
{
_palabrasStatusCache = new Dictionary<int, PumpStatusDefinition>();
try
{
if (!File.Exists(PumpsXml)) return null;
var xdoc = XDocument.Load(PumpsXml);
var palabras = xdoc.Root?.Element("Palabras");
if (palabras == null)
{
// Try top-level sibling — the <Palabras> block is outside </Pumps>.
palabras = xdoc.Descendants("Palabras").FirstOrDefault();
}
if (palabras == null)
{
_log.Error(LogId, "LoadPumpStatusFromPumpsXml: <Palabras> block not found in pumps.xml.");
return null;
}
foreach (var el in palabras.Descendants("PumpStatus"))
{
if (!int.TryParse(el.Attribute("StatusID")?.Value, out var id)) continue;
var parsed = ParsePumpStatusElement(el, id);
if (parsed != null)
_palabrasStatusCache[id] = parsed;
else
_log.Error(LogId, $"LoadPumpStatusFromPumpsXml: malformed PumpStatus StatusID={id} in pumps.xml.");
}
_log.Info(LogId, $"Loaded {_palabrasStatusCache.Count} pump-status tables from pumps.xml <Palabras>.");
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatusFromPumpsXml parse failed: {ex.Message}");
return null;
}
}
_palabrasStatusCache.TryGetValue(statusId, out var def);
if (def == null)
_log.Error(LogId, $"LoadPumpStatus: StatusID={statusId} not found in status.xml or pumps.xml <Palabras>.");
return def;
}
/// <summary>Converts a <c>&lt;PumpStatus&gt;</c> XML element into a <see cref="PumpStatusDefinition"/>.</summary>
private static PumpStatusDefinition ParsePumpStatusElement(XElement xStatus, int statusId)
{
var def = new PumpStatusDefinition
{
Id = statusId,
Name = xStatus.Attribute("name")?.Value ?? "-"
};
foreach (var xState in xStatus.Elements("State"))
{
var bit = new StatusBit
{
Bit = int.Parse(xState.Attribute("bit")?.Value ?? "0"),
Enabled = string.Equals(
xState.Attribute("enabled")?.Value, "true",
StringComparison.OrdinalIgnoreCase)
};
foreach (var xVal in xState.Elements("StateValue"))
{
bit.Values.Add(new StatusBitValue
{
State = int.Parse(xVal.Attribute("value")?.Value ?? "0", CultureInfo.InvariantCulture),
Color = xVal.Attribute("color")?.Value ?? "26C200",
Description = xVal.Value.Trim(),
Reaction = int.Parse(xVal.Attribute("reaction")?.Value ?? "0", CultureInfo.InvariantCulture)
});
}
def.Bits.Add(bit);
}
return def;
}
// ── Private loaders ───────────────────────────────────────────────────────
private void LoadSettings()
@@ -466,6 +768,7 @@ namespace HC_APTBS.Services.Impl
TryInt(r, "MaxRpm", v => _settings.MaxRpm = v);
TryBool(r, "RightRelayValue", v => _settings.RightRelayValue = v);
TryBool(r, "DefaultIgnoreTin", v => _settings.DefaultIgnoreTin = v);
TryBool(r, "AutoTestSkipsOilPumpConfirm", v => _settings.AutoTestSkipsOilPumpConfirm = v);
TryInt(r, "LastRotationDir", v => _settings.LastRotationDirection = (short)v);
TryInt(r, "DaysKeepLogs", v => _settings.DaysKeepLogs = v);
TryString(r, "CompanyName", v => _settings.CompanyName = v);
@@ -599,6 +902,63 @@ namespace HC_APTBS.Services.Impl
}
}
private void LoadCustomCommands()
{
_customCommands = new ObservableCollection<CustomCommand>();
if (!File.Exists(CustomCommandsXml)) return;
try
{
var xdoc = XDocument.Load(CustomCommandsXml);
foreach (var xe in xdoc.Root!.Elements("command"))
{
var name = xe.Attribute("name")?.Value ?? string.Empty;
var hex = xe.Attribute("hex")?.Value ?? string.Empty;
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(hex))
{
_log.Warning(LogId, "LoadCustomCommands: skipping empty <command/> element.");
continue;
}
_customCommands.Add(new CustomCommand { Name = name, HexBytes = hex });
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadCustomCommands failed: {ex.Message}");
}
}
private void LoadEepromPasswords()
{
_eepromPasswords = new ObservableCollection<EepromPassword>();
if (!File.Exists(EepromPasswordsXml)) return;
try
{
var xdoc = XDocument.Load(EepromPasswordsXml);
foreach (var xe in xdoc.Root!.Elements("password"))
{
var name = xe.Attribute("name")?.Value ?? string.Empty;
var zoneStr = xe.Attribute("zone")?.Value ?? "0";
var keyStr = xe.Attribute("key")?.Value ?? "0";
if (!byte.TryParse(zoneStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out byte zone))
{
_log.Warning(LogId, $"LoadEepromPasswords: bad zone '{zoneStr}' in <password name='{name}'/> — skipping.");
continue;
}
if (!ushort.TryParse(keyStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ushort key))
{
_log.Warning(LogId, $"LoadEepromPasswords: bad key '{keyStr}' in <password name='{name}'/> — skipping.");
continue;
}
_eepromPasswords.Add(new EepromPassword { Name = name, Zone = zone, Key = key });
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadEepromPasswords failed: {ex.Message}");
}
}
private void LoadAlarms()
{
_settings ??= new AppSettings();
@@ -729,6 +1089,27 @@ namespace HC_APTBS.Services.Impl
}
}
// ── Parse <Aliases> (optional — equivalence detection) ────────────────
// Per-pump equivalence map: alternative K-Line pumpIDs and ModelReference
// strings that should resolve to this canonical pump. See plan in
// .claude/plans/in-the-pump-identification-velvety-meteor.md.
var xaliases = xpump.Element("Aliases");
if (xaliases != null)
{
foreach (var xkid in xaliases.Elements("KlineId"))
{
var alias = xkid.Value?.Trim();
if (!string.IsNullOrEmpty(alias))
pump.KlineAliases.Add(alias);
}
foreach (var xmref in xaliases.Elements("ModelRef"))
{
var alias = xmref.Value?.Trim();
if (!string.IsNullOrEmpty(alias))
pump.ModelRefAliases.Add(alias);
}
}
// ── Parse <Tests> ─────────────────────────────────────────────────────
var xtests = xpump.Element("Tests");
if (xtests != null)
@@ -752,7 +1133,8 @@ namespace HC_APTBS.Services.Impl
{
try
{
var patternStr = xbit.Attribute("pattern")?.Value ?? "0";
// Accept both "hex" (import script) and legacy "pattern" attribute names.
var patternStr = (xbit.Attribute("hex") ?? xbit.Attribute("pattern"))?.Value ?? "0";
if (patternStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
patternStr = patternStr.Substring(2);
@@ -765,6 +1147,7 @@ namespace HC_APTBS.Services.Impl
bipDef.Bits.Add(new BipStatusDefinition
{
Index = int.Parse(xbit.Attribute("index")?.Value ?? bipDef.Bits.Count.ToString(), CultureInfo.InvariantCulture),
Enabled = !string.Equals(xbit.Attribute("enabled")?.Value, "false",
StringComparison.OrdinalIgnoreCase),
HexPattern = ushort.Parse(patternStr, NumberStyles.HexNumber, CultureInfo.InvariantCulture),

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Infrastructure.Kwp;
@@ -57,6 +58,9 @@ namespace HC_APTBS.Services.Impl
/// <inheritdoc/>
public event Action<double>? DfiRead;
/// <inheritdoc/>
public event Action<ushort>? BipStatusRead;
/// <inheritdoc/>
public event Action? PumpDisconnectRequested;
@@ -245,7 +249,10 @@ namespace HC_APTBS.Services.Impl
// Notify subscribers immediately so the pump definition and its
// tests can start loading while the K-Line read continues.
if (!string.IsNullOrEmpty(ident))
{
_log.Info(LogId, $"PumpIdentified fired: '{ident}'");
PumpIdentified?.Invoke(ident);
}
Report(55, "Reading customer change index...");
kwp.KeepAlive();
@@ -380,7 +387,10 @@ namespace HC_APTBS.Services.Impl
result[KlineKeys.PumpId] = ident;
if (!string.IsNullOrEmpty(ident))
{
_log.Info(LogId, $"PumpIdentified fired (session reuse): '{ident}'");
PumpIdentified?.Invoke(ident);
}
Report(55, "Reading customer change index...");
kwp.KeepAlive();
@@ -614,11 +624,24 @@ namespace HC_APTBS.Services.Impl
// ── IKwpService: fast immobilizer unlock ──────────────────────────────────
/// <inheritdoc/>
public async Task<bool> TryFastUnlockAsync()
public async Task<bool> TryFastUnlockAsync(int unlockType)
{
byte ramByte = unlockType switch
{
1 => 0xA8,
2 => 0xE8,
_ => 0x00
};
if (ramByte == 0x00)
{
_log.Info(LogId, $"TryFastUnlock: unsupported unlockType={unlockType} — skipping");
return false;
}
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "TryFastUnlock: no active K-Line session — skipping");
_log.Info(LogId, $"TryFastUnlock(type={unlockType}): no active K-Line session — skipping");
return false;
}
@@ -627,19 +650,19 @@ namespace HC_APTBS.Services.Impl
_busLock.Wait();
try
{
_log.Info(LogId, "TryFastUnlock: sending unlock command over K-Line");
_log.Info(LogId, $"TryFastUnlock(type={unlockType}): sending unlock command (ram=0x{ramByte:X2}) over K-Line");
var packets = _sessionKwp!.SendCustom(
new List<byte> { 0x02, 0x88, 0x02, 0x03, 0xA8, 0x01, 0x00 });
new List<byte> { 0x02, 0x88, 0x02, 0x03, ramByte, 0x01, 0x00 });
bool nak = packets.Count == 1
&& packets[0] is NakPacket;
_log.Info(LogId, $"TryFastUnlock: {(nak ? "NAK pump rejected" : "ACK pump unlocked")}");
_log.Info(LogId, $"TryFastUnlock(type={unlockType}): {(nak ? "NAK pump rejected" : "ACK pump unlocked")}");
return !nak;
}
catch (Exception ex)
{
_log.Warning(LogId, $"TryFastUnlock failed: {ex.Message}");
_log.Warning(LogId, $"TryFastUnlock(type={unlockType}) failed: {ex.Message}");
return false;
}
finally
@@ -649,6 +672,154 @@ namespace HC_APTBS.Services.Impl
});
}
// ── IKwpService: raw custom packet (developer tools) ──────────────────────
/// <inheritdoc/>
public async Task<IReadOnlyList<byte[]>> SendRawCustomAsync(byte[] payload, CancellationToken ct = default)
{
if (payload is null || payload.Length == 0)
{
_log.Info(LogId, "SendRawCustom: empty payload — skipping");
return Array.Empty<byte[]>();
}
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "SendRawCustom: no active K-Line session — skipping");
return Array.Empty<byte[]>();
}
return await Task.Run<IReadOnlyList<byte[]>>(() =>
{
_busLock.Wait(ct);
try
{
var hex = string.Join(" ", payload.Select(b => b.ToString("X2")));
_log.Info(LogId, $"SendRawCustom: TX {hex}");
var packets = _sessionKwp!.SendCustom(payload.ToList());
var response = packets.Select(p => p.Bytes.ToArray()).ToArray();
_log.Info(LogId, $"SendRawCustom: RX {response.Length} packet(s)");
return response;
}
catch (Exception ex)
{
_log.Warning(LogId, $"SendRawCustom failed: {ex.Message}");
return Array.Empty<byte[]>();
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: typed read primitives (developer tools) ──────────────────
/// <inheritdoc/>
public async Task<IReadOnlyList<byte>> ReadEepromAsync(ushort address, byte count, CancellationToken ct = default)
{
if (count == 0) return Array.Empty<byte>();
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "ReadEeprom: no active K-Line session — skipping");
return Array.Empty<byte>();
}
return await Task.Run<IReadOnlyList<byte>>(() =>
{
_busLock.Wait(ct);
try
{
var bytes = _sessionKwp!.ReadEeprom(address, count);
return bytes != null ? (IReadOnlyList<byte>)bytes : Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(LogId, $"ReadEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
return Array.Empty<byte>();
}
finally
{
_busLock.Release();
}
}, ct);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<byte>> ReadRomEepromAsync(ushort address, byte count, CancellationToken ct = default)
{
if (count == 0) return Array.Empty<byte>();
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "ReadRomEeprom: no active K-Line session — skipping");
return Array.Empty<byte>();
}
return await Task.Run<IReadOnlyList<byte>>(() =>
{
_busLock.Wait(ct);
try
{
var bytes = _sessionKwp!.ReadRomEeprom(address, count);
return bytes != null ? (IReadOnlyList<byte>)bytes : Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(LogId, $"ReadRomEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
return Array.Empty<byte>();
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: BIP status ───────────────────────────────────────────────
/// <inheritdoc/>
public async Task<ushort?> ReadBipStatusAsync(CancellationToken ct = default)
{
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
return null;
return await Task.Run(() =>
{
_busLock.Wait(ct);
try
{
// ReadEeprom (0x19), 2 bytes, at RAM address 0x0106 (ADR-S_BIP_HW_UW).
// Byte order is little-endian, consistent with ReadCustomerChangeAddress.
var packets = _sessionKwp!.SendCustom(
new List<byte> { (byte)PacketCommand.ReadEeprom, 0x02, 0x01, 0x06 });
foreach (var pkt in packets)
{
if (pkt is ReadEepromResponsePacket && pkt.Body.Count >= 2)
{
ushort word = (ushort)((pkt.Body[1] << 8) | pkt.Body[0]);
_log.Debug(LogId, $"ReadBipStatus: 0x{word:X4}");
BipStatusRead?.Invoke(word);
return (ushort?)word;
}
}
_log.Warning(LogId, "ReadBipStatus: no ReadEepromResponse received");
return null;
}
catch (OperationCanceledException) { return null; }
catch (Exception ex)
{
_log.Warning(LogId, $"ReadBipStatus failed: {ex.Message}");
return null;
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: device detection ────────────────────────────────────────
/// <inheritdoc/>

View File

@@ -1,5 +1,6 @@
using System;
using System.Windows;
using HC_APTBS.Infrastructure.Logging;
namespace HC_APTBS.Services.Impl
{
@@ -14,11 +15,19 @@ namespace HC_APTBS.Services.Impl
/// </remarks>
public sealed class LocalizationService : ILocalizationService
{
private const string LogId = "LOCALIZATION";
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;
private readonly IAppLogger _log;
// Single, persistent slot in Application.Resources.MergedDictionaries whose
// Source we re-point on every language change. Mutating an existing
// dictionary's Source triggers WPF's DynamicResource invalidation reliably,
// whereas removing-and-adding entries can leave stale resolved values
// depending on the dictionary's parent/owner state.
private readonly ResourceDictionary _stringsSlot = new();
/// <inheritdoc />
public string CurrentLanguage { get; private set; } = "ESP";
@@ -30,9 +39,14 @@ namespace HC_APTBS.Services.Impl
/// Initialises the localization service and loads the language
/// stored in <see cref="Models.AppSettings.Language"/>.
/// </summary>
public LocalizationService(IConfigurationService config)
public LocalizationService(IConfigurationService config, IAppLogger log)
{
_config = config;
_log = log;
// Mount the persistent slot once. From here on we only update its Source.
Application.Current.Resources.MergedDictionaries.Add(_stringsSlot);
// Load persisted language without saving (already persisted).
LoadDictionary(_config.Settings.Language);
}
@@ -41,8 +55,12 @@ namespace HC_APTBS.Services.Impl
public void SetLanguage(string languageCode)
{
var code = NormaliseCode(languageCode);
_log.Info(LogId, $"SetLanguage('{languageCode}') -> normalised='{code}', current='{CurrentLanguage}'");
if (code == CurrentLanguage)
{
_log.Info(LogId, "SetLanguage: already current, no-op.");
return;
}
LoadDictionary(code);
@@ -66,15 +84,20 @@ namespace HC_APTBS.Services.Impl
var code = NormaliseCode(languageCode);
var uri = code == "ENG" ? EngUri : EspUri;
var dict = new ResourceDictionary { Source = new Uri(uri, UriKind.Absolute) };
try
{
_stringsSlot.Source = new Uri(uri, UriKind.Absolute);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadDictionary({code}) failed to load {uri}: {ex.Message}");
return;
}
var merged = Application.Current.Resources.MergedDictionaries;
if (_currentDictionary != null)
merged.Remove(_currentDictionary);
merged.Add(dict);
_currentDictionary = dict;
CurrentLanguage = code;
_log.Info(LogId,
$"LoadDictionary({code}): {uri} loaded with {_stringsSlot.Count} keys.");
}
private static string NormaliseCode(string code) =>

View File

@@ -51,6 +51,9 @@ namespace HC_APTBS.Services.Impl
/// <inheritdoc/>
public event Action? PumpUnlocked;
/// <inheritdoc/>
public event Action? PumpRelocked;
/// <inheritdoc/>
public bool IsPumpUnlocked => _isPumpUnlocked;
@@ -189,6 +192,7 @@ namespace HC_APTBS.Services.Impl
_isPumpUnlocked = unlocked;
_log.Info(LogId, $"Observer: pump {pump.Id} transitioned {(unlocked ? "LOCKED UNLOCKED" : "UNLOCKED LOCKED")}");
if (unlocked) PumpUnlocked?.Invoke();
else PumpRelocked?.Invoke();
}
// ── Persistent CAN senders ───────────────────────────────────────────────
@@ -391,7 +395,7 @@ namespace HC_APTBS.Services.Impl
_log.Info(LogId, "Attempting K-Line fast unlock (timer shortcut)...");
StatusChanged?.Invoke("Fast unlock attempt...");
bool ack = await _kwp.TryFastUnlockAsync().ConfigureAwait(false);
bool ack = await _kwp.TryFastUnlockAsync(pump.UnlockType).ConfigureAwait(false);
if (!ack)
{
_log.Info(LogId, "Fast unlock NAK or failed — continuing normal wait");