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:
117
Services/AutoTestState.cs
Normal file
117
Services/AutoTestState.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
namespace HC_APTBS.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Phases of the Dashboard "Connect & 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;
|
||||
}
|
||||
}
|
||||
75
Services/IAutoTestOrchestrator.cs
Normal file
75
Services/IAutoTestOrchestrator.cs
Normal 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 & 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();
|
||||
}
|
||||
}
|
||||
@@ -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><Aliases><KlineId></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><Aliases><ModelRef></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>
|
||||
|
||||
@@ -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 0x0000–0x9FFF. 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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
516
Services/Impl/AutoTestOrchestrator.cs
Normal file
516
Services/Impl/AutoTestOrchestrator.cs
Normal 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 & 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 P1–P6 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><Pump></c>
|
||||
/// element in pumps.xml/config.xml for an <c><Aliases></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><PumpStatus StatusID="N"></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><PumpStatus></c> entries from the <c><Palabras></c>
|
||||
/// block in pumps.xml and returns the one matching <paramref name="statusId"/>.
|
||||
/// The orphan block lives outside <c></Pumps></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><PumpStatus></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),
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user