initial commit

This commit is contained in:
2026-04-11 12:45:18 +02:00
commit 6e1b929e2f
1246 changed files with 177580 additions and 0 deletions

23
Services/IAppLogger.cs Normal file
View File

@@ -0,0 +1,23 @@
namespace HC_APTBS.Services
{
/// <summary>
/// Application-level structured logger.
/// Implementations write to dated files under <c>%UserProfile%\.HC_APTBS\log\</c>.
/// </summary>
public interface IAppLogger
{
/// <summary>Logs a fatal or unrecoverable error.</summary>
/// <param name="source">Class or component name (used as a prefix in the log line).</param>
/// <param name="message">Error message.</param>
void Error(string source, string message);
/// <summary>Logs a non-fatal warning.</summary>
void Warning(string source, string message);
/// <summary>Logs a normal informational message.</summary>
void Info(string source, string message);
/// <summary>Logs a verbose debug message.</summary>
void Debug(string source, string message);
}
}

181
Services/IBenchService.cs Normal file
View File

@@ -0,0 +1,181 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Models;
namespace HC_APTBS.Services
{
/// <summary>
/// Orchestrates the test bench: relay control, temperature PID, RPM control,
/// and test sequence execution.
/// </summary>
public interface IBenchService
{
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>Raised when a test sequence starts.</summary>
event Action? TestStarted;
/// <summary>
/// Raised when a test sequence completes (normally or via stop).
/// <c>interrupted</c> is true if stopped early; <c>success</c> is the overall pass/fail result.
/// </summary>
event Action<bool , bool >? TestFinished; //interrupted,success
/// <summary>Raised at each phase transition with the phase name.</summary>
event Action<string >? PhaseChanged; //phaseName
/// <summary>Raised with verbose status text during test execution (e.g. "Conditioning… 45s").</summary>
event Action<string >? VerboseMessage; //message
/// <summary>Raised when a PSG sync-pulse error halts a SVME test.</summary>
event Action? PsgSyncError;
/// <summary>
/// Raised after a phase finishes with its name and pass/fail result.
/// </summary>
event Action<string , bool >? PhaseCompleted; // phaseName,passed
// ── Active pump ───────────────────────────────────────────────────────────
/// <summary>
/// Registers the currently selected pump so that pump-specific CAN parameters
/// (me, FBKW, mepi, RPM, Temp, Tein, Status, Empf3) can be read and written
/// through <see cref="ReadParameter"/> and <see cref="SetParameter"/>.
/// </summary>
void SetActivePump(PumpDefinition? pump);
// ── Bench parameter access ────────────────────────────────────────────────
/// <summary>
/// Returns the current live value of a bench parameter by name.
/// </summary>
/// <param name="parameterName">Bench parameter name (see <see cref="BenchParameterNames"/>).</param>
double ReadBenchParameter(string parameterName);
/// <summary>
/// Returns the current live value of a pump parameter by name.
/// </summary>
/// <param name="parameterName">Pump parameter name (see <see cref="PumpParameterNames"/>).</param>
double ReadPumpParameter(string parameterName);
/// <summary>
/// Returns the current live value of a parameter by name.
/// Looks in the active pump's parameters first, then falls back to bench parameters.
/// Use <see cref="ReadBenchParameter"/> or <see cref="ReadPumpParameter"/> when the source is known.
/// </summary>
/// <param name="parameterName">Parameter name (see <see cref="BenchParameterNames"/> / <see cref="PumpParameterNames"/>).</param>
double ReadParameter(string parameterName);
/// <summary>Sets a pump/bench parameter value in the local parameter map.</summary>
/// <param name="parameterName">Parameter name.</param>
/// <param name="value">New value in engineering units.</param>
void SetParameter(string parameterName, double value);
/// <summary>Flushes all pending parameter changes to the CAN bus.</summary>
/// <param name="messageId">CAN message ID to transmit.</param>
void SendParameters(uint messageId);
// ── Pump control sender ───────────────────────────────────────────────────
/// <summary>
/// Sets the target value for a pump control parameter (me, FBKW, mepi).
/// FBKW is written immediately; ME and PreIn are slew-rate filtered by the periodic sender.
/// </summary>
void SetPumpControlValue(string parameterName, double targetValue);
/// <summary>Starts the periodic CAN sender that transmits pump control parameters.</summary>
void StartPumpSender();
/// <summary>Stops the periodic pump control CAN sender.</summary>
void StopPumpSender();
/// <summary>Starts the periodic MemoryRequest sender that polls the pump ECU for Tein data.</summary>
void StartMemoryRequestSender();
/// <summary>Stops the periodic MemoryRequest sender.</summary>
void StopMemoryRequestSender();
/// <summary>Starts the periodic ElectronicMsg keepalive sender (1-second interval).</summary>
void StartElectronicMsgSender();
/// <summary>Stops the periodic ElectronicMsg keepalive sender.</summary>
void StopElectronicMsgSender();
/// <summary>
/// Raised when a pump control value is set (e.g. during test execution),
/// so the ViewModel can update slider positions. Arguments: parameterName, value.
/// </summary>
event Action<string, double>? PumpControlValueSet;
// ── RPM control ───────────────────────────────────────────────────────────
/// <summary>
/// Commands the bench motor to the specified RPM by computing and applying
/// the corresponding voltage from the RPM-to-voltage lookup table.
/// </summary>
void SetRpm(double rpm);
// ── Temperature control ───────────────────────────────────────────────────
/// <summary>
/// Sets the temperature PID setpoint.
/// </summary>
/// <param name="setpointCelsius">Target temperature in °C.</param>
/// <param name="toleranceCelsius">Acceptable deviation in °C.</param>
/// <returns>
/// True if the temperature check should be ignored (e.g. sensor fault or
/// the DefaultIgnoreTin setting is active).
/// </returns>
bool SetTemperatureSetpoint(double setpointCelsius, double toleranceCelsius);
// ── Relay control ─────────────────────────────────────────────────────────
/// <summary>Energises or de-energises the named relay.</summary>
/// <param name="relayName">Relay name (see <see cref="RelayNames"/>).</param>
/// <param name="state">True = ON, false = OFF.</param>
void SetRelay(string relayName, bool state);
// ── Test execution ────────────────────────────────────────────────────────
/// <summary>
/// Starts the full test sequence for the given pump in a background task.
/// </summary>
/// <param name="pump">Pump definition containing tests and CAN parameters.</param>
/// <param name="ct">Cancellation token — cancel to trigger a controlled stop.</param>
Task RunTestsAsync(PumpDefinition pump, CancellationToken ct);
/// <summary>Requests a controlled stop of the currently running test sequence.</summary>
void StopTests();
// ── Lock angle ────────────────────────────────────────────────────────────
/// <summary>
/// Raised during test execution to signal that the Lock Angle measurement phase
/// is ready. The ViewModel should display the lock angle acquisition UI.
/// </summary>
event Action? LockAngleFaseReady;
/// <summary>
/// Raised when the PSG encoder synchronisation phase begins.
/// </summary>
event Action? PsgModeFaseReady;
// ── DFI auto-adjust ───────────────────────────────────────────────────────
/// <summary>Returns true if auto-DFI adjustment is currently enabled by the operator.</summary>
bool IsAutoDfiEnabled { get; }
/// <summary>
/// Writes a new DFI value to the pump ECU as part of the auto-adjust loop.
/// </summary>
void SetDfi(double dfi);
// ── Tolerance display ─────────────────────────────────────────────────────
/// <summary>
/// Raised so the chart view can draw tolerance bands for the specified parameter.
/// </summary>
event Action<string , double , double >? ToleranceUpdated; //parameterName, value, tolerance
}
}

View File

@@ -0,0 +1,36 @@
using System.Threading;
using System.Threading.Tasks;
namespace HC_APTBS.Services
{
/// <summary>
/// Handles DFI (injection timing offset) calibration read/write operations
/// on the VP44 pump ECU over the K-Line / KWP2000 protocol.
/// </summary>
public interface ICalibrationService
{
// ── DFI ───────────────────────────────────────────────────────────────────
/// <summary>
/// Reads the current DFI calibration value stored in ECU EEPROM.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>DFI offset in degrees.</returns>
Task<double> ReadDfiAsync(CancellationToken ct = default);
/// <summary>
/// Writes a new DFI calibration value to ECU EEPROM and verifies the write
/// by reading the value back.
/// </summary>
/// <param name="dfiDegrees">Target DFI value in degrees (clamped to ±1.45°).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verified DFI value read back after the write.</returns>
Task<double> WriteDfiAsync(double dfiDegrees, CancellationToken ct = default);
/// <summary>
/// Writes DFI and then triggers a pump power cycle to activate the new calibration.
/// Returns the DFI value verified after the restart.
/// </summary>
Task<double> WriteDfiAndRestartAsync(double dfiDegrees, CancellationToken ct = default);
}
}

77
Services/ICanService.cs Normal file
View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using HC_APTBS.Models;
using Peak.Can.Basic;
using TPCANHandle = System.UInt16;
namespace HC_APTBS.Services
{
/// <summary>
/// Abstracts CAN bus communication for the test bench.
/// The implementation (<see cref="HC_APTBS.Infrastructure.Pcan.PcanAdapter"/>) wraps
/// the PEAK PCAN-Basic native API.
/// </summary>
public interface ICanService
{
// ── Events ────────────────────────────────────────────────────────────────
/// <summary>
/// Raised on the CAN read background thread whenever the bus status changes.
/// <c>message</c> is the short status text; <c>isOk</c> is true when status == PCAN_ERROR_OK.
/// </summary>
event Action<string, bool>? StatusChanged;
// ── Properties ────────────────────────────────────────────────────────────
/// <summary>Most recent PCAN status code.</summary>
TPCANStatus CurrentStatus { get; }
/// <summary>True when the CAN read thread is running and the channel is open.</summary>
bool IsConnected { get; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
/// <summary>
/// Opens the PCAN channel, performs OEM legitimation, and starts the receive thread.
/// </summary>
/// <returns>True on success; false if the hardware is unavailable or legitimation fails.</returns>
bool Connect();
/// <summary>Stops the receive thread and releases the PCAN channel.</summary>
void Disconnect();
/// <summary>
/// Sends a baudrate-change command to the bench firmware, then re-initialises the
/// PCAN channel at the new baudrate.
/// </summary>
/// <param name="newBaudrate">Target baudrate.</param>
/// <param name="baudrateMessageId">CAN message ID carrying the baudrate-change command.</param>
void SwitchBaudrate(TPCANBaudrate newBaudrate, uint baudrateMessageId);
// ── Parameter map ─────────────────────────────────────────────────────────
/// <summary>Replaces the entire parameter map with the supplied dictionary.</summary>
void SetParameters(Dictionary<uint, List<CanBusParameter>> parameters);
/// <summary>Adds entries to the parameter map without removing existing ones.</summary>
void AddParameters(Dictionary<uint, List<CanBusParameter>> parameters);
/// <summary>Removes entries whose keys match the supplied dictionary.</summary>
void RemoveParameters(Dictionary<uint, List<CanBusParameter>> parameters);
// ── Transmit ──────────────────────────────────────────────────────────────
/// <summary>
/// Packs all parameters registered for <paramref name="messageId"/> into a standard
/// CAN frame (applying the calibration transfer function) and transmits it.
/// </summary>
void SendMessageById(uint messageId);
/// <summary>
/// Transmits a raw 8-byte CAN standard frame without any parameter lookup.
/// </summary>
/// <param name="messageId">CAN message identifier.</param>
/// <param name="data">Exactly 8 bytes of payload data.</param>
void SendRawMessage(uint messageId, byte[] data);
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using HC_APTBS.Models;
namespace HC_APTBS.Services
{
/// <summary>
/// Provides read/write access to persisted application configuration.
/// Configuration files live under <c>%UserProfile%\.HC_APTBS\config\</c>.
/// </summary>
public interface IConfigurationService
{
// ── Settings ──────────────────────────────────────────────────────────────
/// <summary>Application-wide settings (temperature limits, PID, refresh rates, etc.).</summary>
AppSettings Settings { get; }
/// <summary>Persists <see cref="Settings"/> to <c>config.xml</c>.</summary>
void SaveSettings();
// ── Bench configuration ───────────────────────────────────────────────────
/// <summary>
/// Current bench CAN parameter map and relay definitions.
/// Loaded from <c>bench.xml</c> on first access.
/// </summary>
BenchConfiguration Bench { get; }
/// <summary>Persists <see cref="Bench"/> to <c>bench.xml</c>.</summary>
void SaveBench();
// ── Pump database ─────────────────────────────────────────────────────────
/// <summary>Returns all known pump IDs from the pump database.</summary>
IReadOnlyList<string> GetPumpIds();
/// <summary>
/// Loads the full <see cref="PumpDefinition"/> for the pump with the given ID,
/// including its CAN parameter map and test list.
/// </summary>
PumpDefinition? LoadPump(string pumpId);
/// <summary>Persists a pump definition back to the database.</summary>
void SavePump(PumpDefinition pump);
// ── Clients ───────────────────────────────────────────────────────────────
/// <summary>Sorted client name → contact info dictionary.</summary>
SortedDictionary<string, string> Clients { get; }
/// <summary>Persists the client list to <c>clients.xml</c>.</summary>
void SaveClients();
// ── Pump status definitions ───────────────────────────────────────────────
/// <summary>
/// Loads the <see cref="PumpStatusDefinition"/> for the given status ID.
/// Definitions are cached after first load. Returns null if the ID is not found.
/// </summary>
PumpStatusDefinition? LoadPumpStatus(int statusId);
// ── Sensors ───────────────────────────────────────────────────────────────
/// <summary>Saves updated sensor calibration data to <c>sensors.xml</c>.</summary>
void SaveSensors();
}
}

101
Services/IKwpService.cs Normal file
View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace HC_APTBS.Services
{
/// <summary>
/// Provides KWP2000 / KW1281 diagnostics operations over the ISO K-Line
/// via an FTDI USB-to-K-Line adapter.
/// </summary>
public interface IKwpService
{
// ── Progress reporting ────────────────────────────────────────────────────
/// <summary>
/// Raised during long operations to report completion percentage and a status message.
/// Always marshalled to the UI thread by the implementation.
/// </summary>
event Action<int, string>? ProgressChanged;
// ── Full ECU read ─────────────────────────────────────────────────────────
/// <summary>
/// Connects to the pump ECU over K-Line and reads all available identification data,
/// fault codes, DFI value, serial number, and software versions.
/// </summary>
/// <param name="port">FTDI serial number or COM port identifier.</param>
/// <param name="pumpVersion">KWP protocol variant (0=V1, 1=V2, 2=V3/V4).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// Dictionary with keys from <see cref="HC_APTBS.Models.KlineKeys"/>.
/// The <c>result</c> key is "1" on success, "0" on failure.
/// </returns>
Task<Dictionary<string, string>> ReadAllInfoAsync(
string port, int pumpVersion, CancellationToken ct = default);
// ── DTC operations ────────────────────────────────────────────────────────
/// <summary>Reads all current diagnostic trouble codes from the ECU.</summary>
/// <param name="port">FTDI serial number or COM port identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Formatted fault code string, or "No fault codes".</returns>
Task<string> ReadFaultCodesAsync(string port, CancellationToken ct = default);
/// <summary>Clears all diagnostic trouble codes and returns the post-clear state.</summary>
/// <param name="port">FTDI serial number or COM port identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Post-clear fault code string.</returns>
Task<string> ClearFaultCodesAsync(string port, CancellationToken ct = default);
// ── DFI calibration ───────────────────────────────────────────────────────
/// <summary>Reads the current DFI calibration angle from EEPROM address 0x0044.</summary>
/// <param name="port">FTDI serial number or COM port identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>DFI value in degrees as a formatted string.</returns>
Task<string> ReadDfiAsync(string port, CancellationToken ct = default);
/// <summary>
/// Writes a new DFI calibration angle to EEPROM address 0x0044.
/// </summary>
/// <param name="port">FTDI serial number or COM port identifier.</param>
/// <param name="dfi">Target DFI angle (degrees, range ±1.45°).</param>
/// <param name="version">KWP protocol variant (selects the authentication password).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verified DFI value read back from EEPROM after the write.</returns>
Task<string> WriteDfiAsync(
string port, float dfi, int version, CancellationToken ct = default);
/// <summary>
/// Writes DFI to EEPROM, then triggers a pump power cycle via
/// <see cref="PumpDisconnectRequested"/> / <see cref="PumpReconnectRequested"/>
/// to activate the new calibration.
/// </summary>
Task<string> WriteDfiAndRestartAsync(
string port, float dfi, int version, CancellationToken ct = default);
// ── Device detection ──────────────────────────────────────────────────────
/// <summary>
/// Enumerates connected FTDI USB devices and returns the serial number of the
/// first one found, or <see langword="null"/> if none are connected.
/// </summary>
string? DetectKLinePort();
// ── Power cycle callbacks ─────────────────────────────────────────────────
/// <summary>
/// Raised when the service needs the bench to cut power to the pump
/// (e.g. after a DFI write) before reconnecting.
/// </summary>
event Action? PumpDisconnectRequested;
/// <summary>
/// Raised after <see cref="PumpDisconnectRequested"/> when the service needs
/// the bench to restore power and re-establish the K-Line session.
/// </summary>
event Action? PumpReconnectRequested;
}
}

25
Services/IPdfService.cs Normal file
View File

@@ -0,0 +1,25 @@
using HC_APTBS.Models;
namespace HC_APTBS.Services
{
/// <summary>
/// Generates PDF test reports from completed pump test data.
/// The implementation uses QuestPDF.
/// </summary>
public interface IPdfService
{
/// <summary>
/// Generates a PDF report for the completed test and saves it to <paramref name="outputFolder"/>.
/// </summary>
/// <param name="pump">Pump definition with populated test results.</param>
/// <param name="operatorName">Name of the operator to appear in the report header.</param>
/// <param name="clientName">Client/customer name to appear in the report header.</param>
/// <param name="outputFolder">Directory where the PDF file will be saved.</param>
/// <returns>Full path to the generated PDF file.</returns>
string GenerateReport(
PumpDefinition pump,
string operatorName,
string clientName,
string outputFolder);
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Models;
namespace HC_APTBS.Services
{
/// <summary>
/// Manages the immobilizer unlock sequence required by certain pump ECUs
/// (e.g. Ford VP44 models) before they respond to test commands.
/// </summary>
public interface IUnlockService
{
/// <summary>Raised with progress text during the unlock sequence.</summary>
event Action<string>? StatusChanged;
/// <summary>Raised when the unlock sequence completes. Argument is true if successful.</summary>
event Action<bool>? UnlockCompleted;
/// <summary>
/// Runs the immobilizer unlock sequence for the given pump.
/// Returns immediately if <see cref="PumpDefinition.UnlockType"/> is 0 (no unlock needed).
/// </summary>
/// <param name="pump">Pump definition with unlock type and CAN parameters.</param>
/// <param name="ct">Cancellation token to abort the unlock sequence.</param>
Task UnlockAsync(PumpDefinition pump, CancellationToken ct);
}
}

View File

@@ -0,0 +1,751 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Models;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Implements the full bench test orchestration: RPM control, temperature PID,
/// relay management, and phase-by-phase test execution.
///
/// <para>
/// Test execution runs in a <see cref="Task"/> (background thread). All events
/// are raised on that thread — consumers (ViewModels) must marshal to the UI thread.
/// </para>
/// </summary>
public sealed class BenchService : IBenchService
{
// ── Dependencies ──────────────────────────────────────────────────────────
private readonly ICanService _can;
private readonly IConfigurationService _config;
private readonly IAppLogger _log;
private const string LogId = "BenchService";
// ── State ─────────────────────────────────────────────────────────────────
private PumpDefinition? _activePump;
private CancellationTokenSource? _cts;
private volatile bool _running;
// Temperature PID state
private double _temperatureSetpoint;
private double _temperatureTolerance;
// DFI auto-adjust flag (set by the UI)
private volatile bool _autoDfiEnabled;
// Periodic pump parameter CAN sender
private Task? _pumpSendTask;
private CancellationTokenSource? _pumpSendCts;
private volatile bool _pumpSendingActive;
private double _targetMe;
private double _targetFbkw;
private double _targetPreIn;
// Periodic MemoryRequest sender (for Tein acquisition)
private Task? _memoryRequestTask;
private CancellationTokenSource? _memoryRequestCts;
private volatile bool _memoryRequestActive;
// Periodic ElectronicMsg keepalive sender
private Task? _electronicMsgTask;
private CancellationTokenSource? _electronicMsgCts;
private volatile bool _electronicMsgActive;
// ── Events ────────────────────────────────────────────────────────────────
/// <inheritdoc/>
public event Action? TestStarted;
/// <inheritdoc/>
public event Action<bool, bool>? TestFinished;
/// <inheritdoc/>
public event Action<string>? PhaseChanged;
/// <inheritdoc/>
public event Action<string>? VerboseMessage;
/// <inheritdoc/>
public event Action? PsgSyncError;
/// <inheritdoc/>
public event Action? LockAngleFaseReady;
/// <inheritdoc/>
public event Action? PsgModeFaseReady;
/// <inheritdoc/>
public event Action<string, double, double>? ToleranceUpdated;
/// <inheritdoc/>
public event Action<string, bool>? PhaseCompleted;
/// <inheritdoc/>
public event Action<string, double>? PumpControlValueSet;
// ── Constructor ───────────────────────────────────────────────────────────
/// <param name="canService">CAN bus service for parameter I/O.</param>
/// <param name="configService">Configuration providing bench parameters and app settings.</param>
/// <param name="logger">Application logger.</param>
public BenchService(ICanService canService, IConfigurationService configService, IAppLogger logger)
{
_can = canService;
_config = configService;
_log = logger;
}
// ── IBenchService: properties ─────────────────────────────────────────────
/// <inheritdoc/>
public bool IsAutoDfiEnabled => _autoDfiEnabled;
// ── IBenchService: active pump ────────────────────────────────────────────
/// <inheritdoc/>
public void SetActivePump(PumpDefinition? pump)
{
_activePump = pump;
_log.Debug(LogId, $"Active pump set: {pump?.Id ?? "(none)"}");
}
// ── IBenchService: parameter access ───────────────────────────────────────
/// <inheritdoc/>
public double ReadBenchParameter(string parameterName)
{
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
return benchParam.Value;
return 0;
}
/// <inheritdoc/>
public double ReadPumpParameter(string parameterName)
{
if (_activePump != null &&
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
return pumpParam.Value;
return 0;
}
/// <inheritdoc/>
public double ReadParameter(string parameterName)
{
if (_activePump != null &&
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
return pumpParam.Value;
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
return benchParam.Value;
return 0;
}
/// <inheritdoc/>
public void SetParameter(string parameterName, double value)
{
if (_config.Bench.ParametersByName.TryGetValue(parameterName, out var benchParam))
benchParam.Value = value;
else if (_activePump != null &&
_activePump.ParametersByName.TryGetValue(parameterName, out var pumpParam))
pumpParam.Value = value;
}
/// <inheritdoc/>
public void SendParameters(uint messageId)
=> _can.SendMessageById(messageId);
// ── IBenchService: RPM control ────────────────────────────────────────────
/// <inheritdoc/>
public void SetRpm(double rpm)
{
// Clamp to configured safety limit.
double safeRpm = Math.Min(rpm, _config.Settings.SecurityRpmLimit);
// Look up the required motor control voltage from the RPM-to-voltage table.
double voltage = RpmVoltageRelation.VoltageForRpm(
(int)safeRpm, _config.Settings.Relations);
// Write the voltage value into the RPM parameter and transmit.
SetParameter(BenchParameterNames.Rpm, voltage);
SendParameters(_config.Bench.ParametersByName.TryGetValue(
BenchParameterNames.Rpm, out var rpmParam) ? rpmParam.MessageId : 0x0A);
_log.Debug(LogId, $"SetRpm({safeRpm}) → voltage={voltage:F3}V");
}
// ── IBenchService: temperature ────────────────────────────────────────────
/// <inheritdoc/>
public bool SetTemperatureSetpoint(double setpointCelsius, double toleranceCelsius)
{
_temperatureSetpoint = setpointCelsius;
_temperatureTolerance = toleranceCelsius;
// Return true (ignore temperature) if DefaultIgnoreTin is configured.
return _config.Settings.DefaultIgnoreTin;
}
// ── IBenchService: relay control ──────────────────────────────────────────
/// <inheritdoc/>
public void SetRelay(string relayName, bool state)
{
if (!_config.Bench.Relays.TryGetValue(relayName, out var relay)) return;
relay.State = state;
// Rebuild the relay bitmask and transmit.
TransmitRelayMask(relay.MessageId);
}
private void TransmitRelayMask(uint messageId)
{
// Collect all relays mapped to this CAN message ID and pack their bits.
long mask = 0;
foreach (var relay in _config.Bench.Relays.Values)
{
if (relay.MessageId == messageId && relay.State)
mask |= (1L << relay.Bit);
}
byte[] data = new byte[8];
for (int i = 0; i < 8; i++)
data[i] = (byte)((mask >> (i * 8)) & 0xFF);
_can.SendRawMessage(messageId, data);
}
// ── IBenchService: pump control sender ─────────────────────────────────────
/// <inheritdoc/>
public void SetPumpControlValue(string parameterName, double targetValue)
{
if (_activePump == null) return;
switch (parameterName)
{
case PumpParameterNames.Me:
_targetMe = targetValue;
break;
case PumpParameterNames.Fbkw:
_targetFbkw = targetValue;
// FBKW is written directly with no slew-rate filtering.
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
fbkwParam.Value = targetValue;
break;
case PumpParameterNames.PreIn:
_targetPreIn = targetValue;
break;
}
StartPumpSender();
}
/// <inheritdoc/>
public void StartPumpSender()
{
if (_pumpSendingActive) return;
_pumpSendingActive = true;
_pumpSendCts = new CancellationTokenSource();
var ct = _pumpSendCts.Token;
int intervalMs = Math.Max(1, _config.Settings.RefreshPumpParamsMs);
_pumpSendTask = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
try
{
while (_pumpSendingActive && await timer.WaitForNextTickAsync(ct))
{
PumpSendTick();
}
}
catch (OperationCanceledException) { }
}, ct);
}
/// <inheritdoc/>
public void StopPumpSender()
{
_pumpSendingActive = false;
_pumpSendCts?.Cancel();
_pumpSendCts?.Dispose();
_pumpSendCts = null;
}
private void PumpSendTick()
{
if (_activePump == null) return;
// ME: apply slew-rate IIR filter (alpha * 0.2) before sending.
CanBusParameter? meParam = null;
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Me, out meParam))
{
double alpha = meParam.Alpha * 0.2;
meParam.Value = Math.Round(meParam.Value + alpha * (_targetMe - meParam.Value), 4);
_can.SendMessageById(meParam.MessageId);
}
// FBKW: send its message if it uses a different CAN ID than ME.
if (_activePump.ParametersByName.TryGetValue(PumpParameterNames.Fbkw, out var fbkwParam))
{
if (meParam == null || fbkwParam.MessageId != meParam.MessageId)
_can.SendMessageById(fbkwParam.MessageId);
}
// PreIn: apply slew-rate IIR filter (full alpha) before sending.
if (_activePump.HasPreInjection &&
_activePump.ParametersByName.TryGetValue(PumpParameterNames.PreIn, out var preinParam))
{
preinParam.Value = Math.Round(
preinParam.Value + preinParam.Alpha * (_targetPreIn - preinParam.Value), 4);
_can.SendMessageById(preinParam.MessageId);
}
}
// ── IBenchService: MemoryRequest sender ────────────────────────────────────
/// <inheritdoc/>
public void StartMemoryRequestSender()
{
if (_memoryRequestActive) return;
if (_activePump == null ||
!_activePump.ParametersByName.ContainsKey(PumpParameterNames.MemoryRequest))
return;
_memoryRequestActive = true;
_memoryRequestCts = new CancellationTokenSource();
var ct = _memoryRequestCts.Token;
int intervalMs = Math.Max(1, _config.Settings.RefreshPumpRequestMs);
_memoryRequestTask = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs));
try
{
while (_memoryRequestActive && await timer.WaitForNextTickAsync(ct))
{
if (_activePump?.ParametersByName.TryGetValue(
PumpParameterNames.MemoryRequest, out var memReqParam) == true)
{
_can.SendMessageById(memReqParam.MessageId);
}
}
}
catch (OperationCanceledException) { }
}, ct);
_log.Debug(LogId, "MemoryRequest sender started.");
}
/// <inheritdoc/>
public void StopMemoryRequestSender()
{
_memoryRequestActive = false;
_memoryRequestCts?.Cancel();
_memoryRequestCts?.Dispose();
_memoryRequestCts = null;
_log.Debug(LogId, "MemoryRequest sender stopped.");
}
// ── IBenchService: ElectronicMsg keepalive sender ─────────────────────────
/// <inheritdoc/>
public void StartElectronicMsgSender()
{
if (_electronicMsgActive) return;
_electronicMsgActive = true;
_electronicMsgCts = new CancellationTokenSource();
var ct = _electronicMsgCts.Token;
_electronicMsgTask = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1000));
try
{
while (_electronicMsgActive && await timer.WaitForNextTickAsync(ct))
{
ElectronicMsgTick();
}
}
catch (OperationCanceledException) { }
}, ct);
_log.Debug(LogId, "ElectronicMsg keepalive sender started.");
}
/// <inheritdoc/>
public void StopElectronicMsgSender()
{
_electronicMsgActive = false;
_electronicMsgCts?.Cancel();
_electronicMsgCts?.Dispose();
_electronicMsgCts = null;
_log.Debug(LogId, "ElectronicMsg keepalive sender stopped.");
}
private void ElectronicMsgTick()
{
if (!_config.Bench.ParametersByName.TryGetValue(
BenchParameterNames.ElectronicMsg, out var electronicParam))
return;
byte[] data = new byte[8];
data[0] = 1;
// Encode the encoder resolution into its assigned byte positions.
if (_config.Bench.ParametersByName.TryGetValue(
BenchParameterNames.EncoderResolution, out var encoderParam))
{
int resolution = (int)_config.Settings.EncoderResolution;
data[encoderParam.ByteH] = (byte)((resolution & 0xFF00) >> 8);
data[encoderParam.ByteL] = (byte)(resolution & 0x00FF);
}
_can.SendRawMessage(electronicParam.MessageId, data);
}
// ── IBenchService: DFI ────────────────────────────────────────────────────
/// <inheritdoc/>
public void SetDfi(double dfi)
{
_log.Debug(LogId, $"SetDfi({dfi:F4}) — forwarded to KWP service via event.");
// The ViewModel wires this to IKwpService.WriteDfiAsync in the test flow.
}
// ── IBenchService: test execution ─────────────────────────────────────────
/// <inheritdoc/>
public async Task RunTestsAsync(PumpDefinition pump, CancellationToken ct)
{
_running = true;
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
TestStarted?.Invoke();
// Small delay to allow oil circulation to stabilise before the first test.
await Task.Delay(2000, _cts.Token);
bool overallSuccess = true;
TestDefinition? lastTest = null;
foreach (var test in pump.Tests)
{
if (_cts.Token.IsCancellationRequested) break;
if (!test.HasActivePhase()) continue;
lastTest = test;
bool testSuccess = await RunSingleTestAsync(test, pump, _cts.Token);
overallSuccess = overallSuccess && testSuccess;
if (!testSuccess && ShouldHaltOnFailure(test)) break;
}
bool wasInterrupted = _cts.Token.IsCancellationRequested;
TestFinished?.Invoke(wasInterrupted, wasInterrupted ? false : overallSuccess);
}
catch (OperationCanceledException)
{
TestFinished?.Invoke(true, false);
}
catch (Exception ex)
{
_log.Error(LogId, $"RunTestsAsync exception: {ex}");
TestFinished?.Invoke(true, false);
}
finally
{
_running = false;
}
}
/// <inheritdoc/>
public void StopTests()
{
_cts?.Cancel();
SetRpm(0);
_log.Info(LogId, "Test sequence stopped by operator.");
}
// ── Private: test execution ───────────────────────────────────────────────
private async Task<bool> RunSingleTestAsync(
TestDefinition test, PumpDefinition pump, CancellationToken ct)
{
bool success = true;
foreach (var phase in test.Phases)
{
if (!phase.Enabled || ct.IsCancellationRequested) break;
PhaseChanged?.Invoke(phase.Name);
phase.Success = true;
phase.ClearResults();
phase.ErrorBits.Clear();
// SVME test: check that the PSG encoder sync pulse is present before proceeding.
if (!phase.Name.Contains("Lock Angle") &&
test.Name == TestType.Svme &&
ReadBenchParameter(BenchParameterNames.PsgEncoderWorking) == 0)
{
VerboseMessage?.Invoke($"{phase.Name} — PSG Sync Pulse Error");
PsgSyncError?.Invoke();
return false;
}
// ── Step 1: Set RPM and wait for bench to reach setpoint ──────────
var rpmSetpoint = phase.GetRpmSetpoint();
if (rpmSetpoint != null)
{
SetRpm(rpmSetpoint.Value);
VerboseMessage?.Invoke($"{phase.Name} — Revving to {rpmSetpoint.Value} RPM...");
// Wait on the bench motor encoder feedback (BenchRPM), not the
// ambiguous "RPM" which resolves to pump ECU RPM when a pump is loaded.
await WaitForParameter(
BenchParameterNames.BenchRpm, rpmSetpoint.Value, rpmSetpoint.Tolerance, ct);
// 2-second stabilisation delay after reaching target RPM.
await Task.Delay(2000, ct);
}
ct.ThrowIfCancellationRequested();
var sw = Stopwatch.StartNew();
// ── Step 2: Send pump parameters ──────────────────────────────────
VerboseMessage?.Invoke($"{phase.Name} — Sending pump parameters...");
foreach (var send in phase.Sends)
{
ct.ThrowIfCancellationRequested();
if (send.Name == BenchParameterNames.Rpm) continue; // already handled
// Pump control params route through the periodic sender with slew-rate filtering.
if (send.Name == PumpParameterNames.Me ||
send.Name == PumpParameterNames.Fbkw ||
send.Name == PumpParameterNames.PreIn)
{
SetPumpControlValue(send.Name, send.Value);
PumpControlValueSet?.Invoke(send.Name, send.Value);
}
else
{
SetParameter(send.Name, send.Value);
}
}
// Transmit bench parameters that were set above.
foreach (var send in phase.Sends)
{
if (send.Name == BenchParameterNames.Rpm ||
send.Name == PumpParameterNames.Me ||
send.Name == PumpParameterNames.Fbkw ||
send.Name == PumpParameterNames.PreIn) continue;
if (_config.Bench.ParametersByName.TryGetValue(send.Name, out var sp))
{
SendParameters(sp.MessageId);
break; // one frame per message ID group is enough
}
}
// ── Step 3: Wait for temperature setpoint ─────────────────────────
if (phase.Readies.Count > 0)
{
var tempReady = phase.Readies[0];
bool ignoreTin = SetTemperatureSetpoint(tempReady.Value, tempReady.Tolerance);
if (!ignoreTin)
{
VerboseMessage?.Invoke($"{phase.Name} — Adjusting temperature...");
await WaitForParameter(
tempReady.Name, tempReady.Value, tempReady.Tolerance, ct);
}
}
// Notify chart view of expected tolerance bands.
foreach (var recv in phase.Receives)
{
if (recv.Name == BenchParameterNames.QDelivery ||
recv.Name == BenchParameterNames.QOver)
ToleranceUpdated?.Invoke(recv.Name, recv.Value, recv.Tolerance);
}
// ── Step 4: Conditioning time countdown ───────────────────────────
sw.Stop();
long conditioningRemainMs = (long)test.ConditioningTimeSec * 1000 - sw.ElapsedMilliseconds;
_log.Debug(LogId, $"{phase.Name}: conditioning remaining={conditioningRemainMs}ms");
for (int i = 0; i * 1000 < conditioningRemainMs; i++)
{
ct.ThrowIfCancellationRequested();
int remaining = (int)(conditioningRemainMs / 1000) - i;
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {remaining}s");
await Task.Delay(1000, ct);
}
ct.ThrowIfCancellationRequested();
// ── Step 5: Special phase handling ────────────────────────────────
if (phase.Name.Contains("Lock Angle"))
{
VerboseMessage?.Invoke($"{phase.Name} — Acquiring Lock Angle...");
LockAngleFaseReady?.Invoke();
await Task.Delay(200, ct);
continue; // No measurement collection for lock angle phases
}
if (phase.Name.Contains(TestDefinition.XmlPhase) || phase.Name.Contains("PSG(0)"))
{
VerboseMessage?.Invoke($"{phase.Name} — PSG Mode...");
PsgModeFaseReady?.Invoke();
await Task.Delay(4000, ct);
continue;
}
// ── Step 6: DFI auto-adjust loop ──────────────────────────────────
if (test.Name == TestType.Dfi && _autoDfiEnabled)
{
phase.Success = await RunDfiAutoAdjustAsync(test, phase, ct);
}
else
{
phase.Success = await MeasurePhaseAsync(test, phase, ct);
}
success = success && phase.Success;
PhaseCompleted?.Invoke(phase.Name, phase.Success);
// Critical phase failure halts the entire test.
if (phase.IsCritical && !phase.Success)
{
SetRpm(0);
VerboseMessage?.Invoke($"CRITICAL failure in {phase.Name} — test halted.");
return false;
}
// Stop pump between phases (motor cool-down).
SetRpm(0);
}
return success;
}
/// <summary>
/// Collects measurements for <paramref name="phase"/> over the measurement window,
/// then evaluates pass/fail for each receive parameter.
/// </summary>
private async Task<bool> MeasurePhaseAsync(
TestDefinition test, PhaseDefinition phase, CancellationToken ct)
{
VerboseMessage?.Invoke($"{phase.Name} — Collecting measurements...");
// Clear previous results.
foreach (var tp in phase.Receives)
tp.Result = new TestResult { TestType = test.Name };
long measureMs = (long)test.MeasurementTimeSec * 1000;
int sleepMs = (int)(1000.0 / Math.Max(test.MeasurementsPerSecond, 0.1));
var sw = Stopwatch.StartNew();
while (sw.ElapsedMilliseconds <= measureMs)
{
ct.ThrowIfCancellationRequested();
foreach (var tp in phase.Receives)
{
var sample = new MeasurementSample
{
Value = ReadParameter(tp.Name),
Timestamp = DateTime.Now.ToString(TestDefinition.TimestampFormat)
};
tp.Result!.AddSample(sample);
}
await Task.Delay(sleepMs, ct);
}
// Evaluate pass/fail.
bool phaseSuccess = true;
foreach (var tp in phase.Receives)
{
tp.Result!.Evaluate(
tp.Value, tp.Tolerance,
_config.Settings.ToleranceUpExtension,
_config.Settings.TolerancePfpExtension);
phaseSuccess = phaseSuccess && tp.Result.Passed;
}
return phaseSuccess;
}
/// <summary>
/// Auto-adjusts the DFI calibration angle iteratively until the measured delivery
/// matches the target, or the test is cancelled.
///
/// <para>
/// The relationship between DFI angle and delivery quantity was determined
/// experimentally: <c>delivery = 15.5 × DFI + 32.3</c>.
/// The inverse gives the required DFI to achieve a target delivery.
/// </para>
/// </summary>
private async Task<bool> RunDfiAutoAdjustAsync(
TestDefinition test, PhaseDefinition phase, CancellationToken ct)
{
// Baseline measurement.
await MeasurePhaseAsync(test, phase, ct);
if (phase.Receives.Count == 0) return false;
var target = phase.Receives[0];
if (target.Result == null || Math.Abs(target.Result.Average - target.Value) <= target.Tolerance)
return target.Result?.Passed ?? false;
double initialDfi = ReadParameter(KlineKeys.Dfi);
// Empirically derived linear model: delivery = 15.5 × DFI + 32.3
double displacement = 15.5 * initialDfi + 32.3 - target.Result.Average;
double lastError = 0;
while (!ct.IsCancellationRequested)
{
if (Math.Abs(target.Result!.Average - target.Value) <= target.Tolerance)
break;
double sendDfi = (target.Value + displacement - 32.3 + lastError) / 15.5;
sendDfi = Math.Clamp(sendDfi, -1.45, 1.45); // Hardware DFI range ±1.45°
VerboseMessage?.Invoke($"{phase.Name} — Writing DFI: {sendDfi:F4}°");
SetDfi(sendDfi);
// Re-conditioning period before re-measuring.
for (int i = 0; i < test.ConditioningTimeSec; i++)
{
ct.ThrowIfCancellationRequested();
VerboseMessage?.Invoke($"{phase.Name} — Conditioning... {test.ConditioningTimeSec - i}s");
await Task.Delay(2000, ct); // 2 s steps — gives ECU time to reinitialise K-Line
}
await MeasurePhaseAsync(test, phase, ct);
lastError = target.Value - target.Result.Average;
}
return target.Result?.Passed ?? false;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task WaitForParameter(
string name, double target, double tolerance, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
double current = ReadParameter(name);
if (Math.Abs(current - target) <= tolerance) return;
await Task.Delay(5, ct);
}
}
private static bool ShouldHaltOnFailure(TestDefinition test)
{
// Currently no test-level "critical" flag; only phase-level.
// Preserve this as an extension point.
return false;
}
}
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Delegates DFI calibration operations to <see cref="IKwpService"/>,
/// resolving the K-Line port from <see cref="IConfigurationService"/>.
/// </summary>
public sealed class CalibrationService : ICalibrationService
{
private readonly IKwpService _kwp;
private readonly IConfigurationService _config;
/// <param name="kwpService">K-Line protocol service.</param>
/// <param name="configService">Provides the K-Line port setting.</param>
public CalibrationService(IKwpService kwpService, IConfigurationService configService)
{
_kwp = kwpService;
_config = configService;
}
// ── ICalibrationService ────────────────────────────────────────────────────
/// <inheritdoc/>
public async Task<double> ReadDfiAsync(CancellationToken ct = default)
{
string raw = await _kwp.ReadDfiAsync(Port, ct);
return double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v) ? v : 0;
}
/// <inheritdoc/>
public async Task<double> WriteDfiAsync(double dfiDegrees, CancellationToken ct = default)
{
int version = _config.Settings.PidLoopMs; // pump version stored via config TODO: expose
string raw = await _kwp.WriteDfiAsync(Port, (float)dfiDegrees, version, ct);
return double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v) ? v : 0;
}
/// <inheritdoc/>
public async Task<double> WriteDfiAndRestartAsync(double dfiDegrees, CancellationToken ct = default)
{
int version = _config.Settings.PidLoopMs; // pump version
string raw = await _kwp.WriteDfiAndRestartAsync(Port, (float)dfiDegrees, version, ct);
return double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v) ? v : 0;
}
private string Port => _kwp.DetectKLinePort() ?? string.Empty;
}
}

View File

@@ -0,0 +1,693 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Xml.Linq;
using HC_APTBS.Models;
using Peak.Can.Basic;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// XML-backed implementation of <see cref="IConfigurationService"/>.
/// All configuration files live under <c>%UserProfile%\.HC_APTBS\config\</c>.
/// </summary>
public sealed class ConfigurationService : IConfigurationService
{
// ── Paths ─────────────────────────────────────────────────────────────────
private static readonly string ConfigFolder =
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 readonly IAppLogger _log;
private const string LogId = "ConfigurationService";
// ── Cached instances ──────────────────────────────────────────────────────
private AppSettings? _settings;
private BenchConfiguration? _bench;
private SortedDictionary<string, string>? _clients;
private readonly Dictionary<int, PumpStatusDefinition> _statusCache = new();
// ── Constructor ───────────────────────────────────────────────────────────
/// <param name="logger">Application logger.</param>
public ConfigurationService(IAppLogger logger)
{
_log = logger;
Directory.CreateDirectory(ConfigFolder);
}
// ── IConfigurationService: Settings ───────────────────────────────────────
/// <inheritdoc/>
public AppSettings Settings
{
get
{
if (_settings == null) LoadSettings();
return _settings!;
}
}
/// <inheritdoc/>
public void SaveSettings()
{
try
{
var root = new XElement("Config",
new XElement("TMax", Settings.TempMax),
new XElement("TMin", Settings.TempMin),
new XElement("RefreshBenchInterface", Settings.RefreshBenchInterfaceMs),
new XElement("RefreshWhileReading", Settings.RefreshWhileReadingMs),
new XElement("RefreshCanBusRead", Settings.RefreshCanBusReadMs),
new XElement("RefreshPumpRequest", Settings.RefreshPumpRequestMs),
new XElement("RefreshPumpParams", Settings.RefreshPumpParamsMs),
new XElement("BlinkInterval", Settings.BlinkIntervalMs),
new XElement("FlasherInterval", Settings.FlasherIntervalMs),
new XElement("PidP", Settings.PidP),
new XElement("PidI", Settings.PidI),
new XElement("PidD", Settings.PidD),
new XElement("PidMs", Settings.PidLoopMs),
new XElement("SecurityRpm", Settings.SecurityRpmLimit),
new XElement("MaxPressure", Settings.MaxPressureBar),
new XElement("ToleranceUp", Settings.ToleranceUpExtension),
new XElement("TolerancePfp", Settings.TolerancePfpExtension),
new XElement("EncoderResolution", Settings.EncoderResolution),
new XElement("VoltageForMaxRpm", Settings.VoltageForMaxRpm),
new XElement("MaxRpm", Settings.MaxRpm),
new XElement("RightRelayValue", Settings.RightRelayValue),
new XElement("DefaultIgnoreTin", Settings.DefaultIgnoreTin),
new XElement("LastRotationDir", Settings.LastRotationDirection),
new XElement("DaysKeepLogs", Settings.DaysKeepLogs),
new XElement("CompanyName", Settings.CompanyName),
new XElement("CompanyInfo", Settings.CompanyInfo),
new XElement("ReportLogoPath", Settings.ReportLogoPath),
new XElement("KLinePort", Settings.KLinePort),
new XElement("Language", Settings.Language),
new XElement("Relations", RpmVoltageRelation.Serialise(Settings.Relations))
);
new XDocument(root).Save(ConfigXml);
SaveSensors();
SaveClients();
_log.Info(LogId, "Settings saved.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveSettings failed: {ex.Message}");
}
}
// ── IConfigurationService: Bench ──────────────────────────────────────────
/// <inheritdoc/>
public BenchConfiguration Bench
{
get
{
if (_bench == null) LoadBench();
return _bench!;
}
}
/// <inheritdoc/>
public void SaveBench()
{
try
{
var root = new XElement("Bench");
foreach (var param in Bench.ParametersByName.Values)
root.Add(param.ToXml());
var relesNode = new XElement("Reles");
foreach (var relay in Bench.Relays.Values)
relesNode.Add(relay.ToXml());
root.Add(relesNode);
new XDocument(root).Save(BenchXml);
_log.Info(LogId, "Bench configuration saved.");
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveBench failed: {ex.Message}");
}
}
// ── IConfigurationService: Pumps ──────────────────────────────────────────
/// <inheritdoc/>
public IReadOnlyList<string> GetPumpIds()
{
var ids = new List<string>();
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null) return ids;
try
{
var xdoc = XDocument.Load(source);
foreach (var xid in xdoc.Descendants("PumpID"))
ids.Add(xid.Value);
}
catch (Exception ex)
{
_log.Error(LogId, $"GetPumpIds failed: {ex.Message}");
}
return ids;
}
/// <inheritdoc/>
public PumpDefinition? LoadPump(string pumpId)
{
string source = File.Exists(PumpsXml) ? PumpsXml
: File.Exists(ConfigXml) ? ConfigXml
: null!;
if (source == null) return null;
try
{
var xdoc = XDocument.Load(source);
var xpump = xdoc.XPathSelectElement($"/Config/Pumps/Pump[@id='{pumpId}']");
if (xpump == null) return null;
return ParsePumpElement(xpump);
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPump({pumpId}) failed: {ex.Message}");
return null;
}
}
/// <inheritdoc/>
public void SavePump(PumpDefinition pump)
{
_log.Info(LogId, $"SavePump({pump.Id}) — not yet implemented.");
}
// ── IConfigurationService: Clients ────────────────────────────────────────
/// <inheritdoc/>
public SortedDictionary<string, string> Clients
{
get
{
if (_clients == null) LoadClients();
return _clients!;
}
}
/// <inheritdoc/>
public void SaveClients()
{
try
{
var root = new XElement("Clients");
foreach (var kv in Clients)
root.Add(new XElement("client",
new XAttribute("name", kv.Key),
new XAttribute("contact", kv.Value)));
new XDocument(root).Save(ClientsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveClients failed: {ex.Message}");
}
}
// ── IConfigurationService: Sensors ────────────────────────────────────────
/// <inheritdoc/>
public void SaveSensors()
{
try
{
var root = new XElement("Sensors");
foreach (var s in Settings.Sensors.Values)
root.Add(s.ToXml());
new XDocument(root).Save(SensorsXml);
}
catch (Exception ex)
{
_log.Error(LogId, $"SaveSensors failed: {ex.Message}");
}
}
// ── Pump status definitions ───────────────────────────────────────────────
/// <inheritdoc/>
public PumpStatusDefinition? LoadPumpStatus(int statusId)
{
if (_statusCache.TryGetValue(statusId, out var cached))
return cached;
try
{
if (!File.Exists(StatusXml))
{
_log.Error(LogId, $"LoadPumpStatus: {StatusXml} not found.");
return null;
}
var xdoc = XDocument.Load(StatusXml);
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"))
{
if (el.Attribute("StatusID")?.Value == statusId.ToString())
{
xStatus = el;
break;
}
}
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"),
Color = xVal.Attribute("color")?.Value ?? "26C200",
Description = xVal.Value.Trim()
});
}
def.Bits.Add(bit);
}
_statusCache[statusId] = def;
return def;
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadPumpStatus({statusId}) failed: {ex.Message}");
return null;
}
}
// ── Private loaders ───────────────────────────────────────────────────────
private void LoadSettings()
{
_settings = new AppSettings();
if (!File.Exists(ConfigXml)) return;
try
{
var xdoc = XDocument.Load(ConfigXml);
var r = xdoc.Root!;
TryInt(r, "TMax", v => _settings.TempMax = v);
TryInt(r, "TMin", v => _settings.TempMin = v);
TryInt(r, "RefreshBenchInterface", v => _settings.RefreshBenchInterfaceMs = v);
TryInt(r, "RefreshWhileReading", v => _settings.RefreshWhileReadingMs = v);
TryInt(r, "RefreshCanBusRead", v => _settings.RefreshCanBusReadMs = v);
TryInt(r, "RefreshPumpRequest", v => _settings.RefreshPumpRequestMs = v);
TryInt(r, "RefreshPumpParams", v => _settings.RefreshPumpParamsMs = v);
TryInt(r, "BlinkInterval", v => _settings.BlinkIntervalMs = v);
TryInt(r, "FlasherInterval", v => _settings.FlasherIntervalMs = v);
TryDouble(r, "PidP", v => _settings.PidP = v);
TryDouble(r, "PidI", v => _settings.PidI = v);
TryDouble(r, "PidD", v => _settings.PidD = v);
TryInt(r, "PidMs", v => _settings.PidLoopMs = v);
TryInt(r, "SecurityRpm", v => _settings.SecurityRpmLimit = v);
TryInt(r, "MaxPressure", v => _settings.MaxPressureBar = v);
TryDouble(r, "ToleranceUp", v => _settings.ToleranceUpExtension = v);
TryDouble(r, "TolerancePfp", v => _settings.TolerancePfpExtension = v);
TryInt(r, "EncoderResolution", v => _settings.EncoderResolution = v);
TryDouble(r, "VoltageForMaxRpm", v => _settings.VoltageForMaxRpm = v);
TryInt(r, "MaxRpm", v => _settings.MaxRpm = v);
TryBool(r, "RightRelayValue", v => _settings.RightRelayValue = v);
TryBool(r, "DefaultIgnoreTin", v => _settings.DefaultIgnoreTin = v);
TryInt(r, "DaysKeepLogs", v => _settings.DaysKeepLogs = v);
TryString(r, "CompanyName", v => _settings.CompanyName = v);
TryString(r, "CompanyInfo", v => _settings.CompanyInfo = v);
TryString(r, "ReportLogoPath", v => _settings.ReportLogoPath = v);
TryString(r, "KLinePort", v => _settings.KLinePort = v);
TryString(r, "Language", v => _settings.Language = v);
TryString(r, "Relations", v => _settings.Relations = RpmVoltageRelation.Deserialise(v));
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadSettings failed: {ex.Message}");
}
LoadSensors();
LoadClients();
LoadAlarms();
}
private void LoadBench()
{
_bench = new BenchConfiguration();
string xml = File.Exists(BenchXml)
? File.ReadAllText(BenchXml)
: DefaultBenchXml();
try
{
var xdoc = XDocument.Parse(xml);
foreach (var xe in xdoc.Root!.Elements())
{
if (xe.Name.LocalName == "Reles")
{
foreach (var xr in xe.Elements("Rele"))
ParseRelayElement(xr);
continue;
}
var param = ParseParamElement(xe);
_bench.ParametersByName[param.Name] = param;
if (!_bench.ParametersById.TryGetValue(param.MessageId, out var list))
{
list = new List<CanBusParameter>();
_bench.ParametersById[param.MessageId] = list;
}
list.Add(param);
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadBench failed: {ex.Message}");
}
}
private void LoadSensors()
{
_settings ??= new AppSettings();
if (!File.Exists(SensorsXml))
{
_settings.Sensors[1] = SensorConfiguration.DefaultPressureSensor();
return;
}
try
{
var xdoc = XDocument.Load(SensorsXml);
foreach (var xs in xdoc.Root!.Elements("sensor"))
{
var sc = SensorConfiguration.FromXml(xs);
_settings.Sensors[sc.Number] = sc;
}
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadSensors failed: {ex.Message}");
}
}
private void LoadClients()
{
_clients = new SortedDictionary<string, string>();
if (!File.Exists(ClientsXml)) return;
try
{
var xdoc = XDocument.Load(ClientsXml);
foreach (var xc in xdoc.Root!.Elements("client"))
_clients[xc.Attribute("name")?.Value ?? ""] = xc.Attribute("contact")?.Value ?? "";
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadClients failed: {ex.Message}");
}
}
private void LoadAlarms()
{
_settings ??= new AppSettings();
if (!File.Exists(AlarmsXml)) return;
try
{
var xdoc = XDocument.Load(AlarmsXml);
foreach (var xa in xdoc.Root!.Elements("Alarm"))
_settings.Alarms.Add(Alarm.FromXml(xa));
}
catch (Exception ex)
{
_log.Error(LogId, $"LoadAlarms failed: {ex.Message}");
}
}
// ── Parsing helpers ───────────────────────────────────────────────────────
private static CanBusParameter ParseParamElement(XElement xe)
{
var p = new CanBusParameter
{
Name = xe.Name.LocalName,
MessageId = Convert.ToUInt32(xe.Attribute("id")?.Value ?? "0", 16),
ByteH = ushort.Parse(xe.Attribute("byteh")?.Value ?? "0"),
ByteL = ushort.Parse(xe.Attribute("bytel")?.Value ?? "0"),
Alpha = double.Parse(xe.Attribute("filter")?.Value ?? "1",
System.Globalization.CultureInfo.InvariantCulture),
DisableCalibration = bool.Parse(xe.Attribute("disableparams")?.Value ?? "true"),
// Bench params default to receive unless explicitly marked send="true".
IsReceive = !string.Equals(xe.Attribute("send")?.Value, "true",
StringComparison.OrdinalIgnoreCase)
};
if (!p.DisableCalibration)
{
p.P1 = double.Parse(xe.Attribute("p1")?.Value ?? "1", System.Globalization.CultureInfo.InvariantCulture);
p.P2 = double.Parse(xe.Attribute("p2")?.Value ?? "0", System.Globalization.CultureInfo.InvariantCulture);
p.P3 = double.Parse(xe.Attribute("p3")?.Value ?? "0", System.Globalization.CultureInfo.InvariantCulture);
p.P4 = double.Parse(xe.Attribute("p4")?.Value ?? "1", System.Globalization.CultureInfo.InvariantCulture);
p.P5 = double.Parse(xe.Attribute("p5")?.Value ?? "0", System.Globalization.CultureInfo.InvariantCulture);
p.P6 = double.Parse(xe.Attribute("p6")?.Value ?? "0", System.Globalization.CultureInfo.InvariantCulture);
}
else
{
p.SetIdentityCalibration();
}
return p;
}
private void ParseRelayElement(XElement xr)
{
var relay = new Relay(
xr.Attribute("name")?.Value ?? "",
Convert.ToUInt32(xr.Attribute("id")?.Value ?? "0", 16),
int.Parse(xr.Attribute("bit")?.Value ?? "0"));
_bench!.Relays[relay.Name] = relay;
}
private static PumpDefinition? ParsePumpElement(XElement xpump)
{
var pump = new PumpDefinition
{
Id = xpump.Attribute("id")?.Value ?? string.Empty,
Model = xpump.Attribute("model")?.Value ?? string.Empty,
EcuText = xpump.Attribute("text")?.Value ?? string.Empty,
Chaveta = xpump.Attribute("chaveta")?.Value ?? string.Empty,
Rotation = xpump.Attribute("rotation")?.Value ?? RotationDirection.RightName,
Info = xpump.Attribute("info")?.Value ?? string.Empty,
HasPreInjection = string.Equals(xpump.Attribute("preinjection")?.Value,
"true", StringComparison.OrdinalIgnoreCase),
Is4Cylinder = string.Equals(xpump.Attribute("cilinders4")?.Value,
"true", StringComparison.OrdinalIgnoreCase),
UnlockType = int.Parse(xpump.Attribute("unlock")?.Value ?? "0"),
CanBaudrate = xpump.Attribute("baudrate")?.Value == "250"
? TPCANBaudrate.PCAN_BAUD_250K
: TPCANBaudrate.PCAN_BAUD_500K,
};
// Parse lockangle — may use comma as decimal separator.
var lockStr = xpump.Attribute("lockangle")?.Value;
if (!string.IsNullOrEmpty(lockStr) &&
double.TryParse(lockStr.Replace(',', '.'),
NumberStyles.Float, CultureInfo.InvariantCulture, out var la))
{
pump.LockAngle = la;
}
// ── Parse <Params> ────────────────────────────────────────────────────
var xparams = xpump.Element("Params");
if (xparams != null)
{
foreach (var xe in xparams.Elements())
{
var param = CanBusParameter.FromXml(xe);
pump.ParametersByName[param.Name] = param;
if (!pump.ParametersById.ContainsKey(param.MessageId))
pump.ParametersById[param.MessageId] = new List<CanBusParameter>();
pump.ParametersById[param.MessageId].Add(param);
}
// Safety net: pump RPM is always a receive parameter.
if (pump.ParametersByName.TryGetValue(PumpParameterNames.Rpm, out var pumpRpm)
&& !pumpRpm.IsReceive)
{
pumpRpm.IsReceive = true;
}
}
// ── Parse <Tests> ─────────────────────────────────────────────────────
var xtests = xpump.Element("Tests");
if (xtests != null)
{
foreach (var xtest in xtests.Elements("Test"))
{
var test = TestDefinition.FromXml(xtest);
if (test.Name == TestType.Wl)
pump.CombineTestWL(test);
else
pump.Tests.Add(test);
}
}
return pump;
}
// ── Default bench XML ─────────────────────────────────────────────────────
/// <summary>Returns the factory-default bench parameter XML used when bench.xml is absent.</summary>
private static string DefaultBenchXml() => @"<?xml version=""1.0"" encoding=""utf-8""?>
<Bench>
<RPM id=""A"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
<Counter id=""B"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
<BaudRate id=""37"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
<BenchRPM id=""D"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" />
<BenchCounter id=""D"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""true"" />
<BenchTemp id=""E"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
<T-in id=""E"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
<T-out id=""E"" byteh=""5"" bytel=""4"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
<T4 id=""E"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
<QDelivery id=""8"" byteh=""5"" bytel=""3"" filter=""0.01"" disableparams=""false"" p1=""0"" p2=""2.03"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
<QOver id=""8"" byteh=""2"" bytel=""0"" filter=""0.11"" disableparams=""false"" p1=""0"" p2=""0.51"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
<PSGEncoderValue id=""32"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
<PSGEncoderWorking id=""32"" byteh=""7"" bytel=""7"" filter=""1"" disableparams=""true"" />
<InyectorEncoderValue id=""32"" byteh=""2"" bytel=""3"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
<InyectorEncoderWorking id=""32"" byteh=""6"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
<ManualEncoderValue id=""32"" byteh=""0"" bytel=""1"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
<EncoderResolution id=""33"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" send=""true"" />
<ElectronicMsg id=""33"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
<Alarms id=""8"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""true"" />
<Pressure id=""D"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""true"" />
<AnalogicSensor2 id=""D"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" />
<Reles>
<Rele name=""Electronic"" id=""F"" bit=""0"" />
<Rele name=""OilPump"" id=""F"" bit=""4"" />
<Rele name=""DepositCooler"" id=""F"" bit=""8"" />
<Rele name=""DepositHeater"" id=""F"" bit=""12"" />
<Rele name=""Reserve"" id=""F"" bit=""16"" />
<Rele name=""Counter"" id=""F"" bit=""20"" />
<Rele name=""Direction"" id=""F"" bit=""24"" />
<Rele name=""TinCooler"" id=""F"" bit=""28"" />
<Rele name=""Pulse4Signal"" id=""F"" bit=""32"" />
<Rele name=""Flasher"" id=""F"" bit=""44"" />
</Reles>
</Bench>";
// ── XML parse helpers ─────────────────────────────────────────────────────
private static void TryInt(XElement root, string name, Action<int> assign)
{
try { if (int.TryParse(root.Element(name)?.Value, out int v)) assign(v); }
catch { /* ignore malformed XML */ }
}
private static void TryDouble(XElement root, string name, Action<double> assign)
{
try
{
var val = root.Element(name)?.Value;
if (val != null && double.TryParse(val,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v)) assign(v);
}
catch { }
}
private static void TryBool(XElement root, string name, Action<bool> assign)
{
try { if (bool.TryParse(root.Element(name)?.Value, out bool v)) assign(v); }
catch { }
}
private static void TryString(XElement root, string name, Action<string> assign)
{
try { var v = root.Element(name)?.Value; if (v != null) assign(v); }
catch { }
}
}
// ── XPath extension shim ──────────────────────────────────────────────────────
internal static class XDocumentExtensions
{
/// <summary>Minimal XPath-style element selector used to find pump elements by attribute.</summary>
internal static XElement? XPathSelectElement(this XDocument doc, string xpath)
{
// Parse "/Config/Pumps/Pump[@id='xxx']"
// Sufficient for the pump-lookup use case; not a general XPath engine.
try
{
var parts = xpath.TrimStart('/').Split('/');
XElement? current = doc.Root;
// Skip the first part when it names the root element (e.g. "/Config/..." with root <Config>)
int startIndex = (parts.Length > 0 && current?.Name.LocalName == parts[0]) ? 1 : 0;
for (int pi = startIndex; pi < parts.Length; pi++)
{
if (current == null) return null;
var part = parts[pi];
int attrStart = part.IndexOf('[');
if (attrStart < 0)
{
current = current.Element(part);
}
else
{
string elemName = part[..attrStart];
string attrExpr = part[(attrStart + 1)..^1]; // strip [ and ]
if (attrExpr.StartsWith("@"))
{
var eqIdx = attrExpr.IndexOf('=');
string attrName = attrExpr[1..eqIdx];
string attrValue = attrExpr[(eqIdx + 1)..].Trim('\'', '"');
var parent = current;
current = null;
foreach (var child in parent.Elements(elemName))
{
if (child.Attribute(attrName)?.Value == attrValue)
{ current = child; break; }
}
}
}
}
return current;
}
catch { return null; }
}
}
}

463
Services/Impl/KwpService.cs Normal file
View File

@@ -0,0 +1,463 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Infrastructure.Kwp;
using HC_APTBS.Infrastructure.Kwp.Packets;
using HC_APTBS.Models;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Implements <see cref="IKwpService"/> using the FTDI USB-to-K-Line adapter
/// and the KW1281 protocol stack from <see cref="HC_APTBS.Infrastructure.Kwp"/>.
///
/// <para>
/// The ECU initialisation address for all VP44 pumps is <c>0xF1</c> (broadcast).
/// K-Line baud rate is 9600 bps.
/// </para>
/// </summary>
public sealed class KwpService : IKwpService
{
// ── Protocol constants ────────────────────────────────────────────────────
/// <summary>ECU initialisation address used in the 5-baud wake-up sequence.</summary>
private const byte EcuInitAddress = 0xF1;
/// <summary>K-Line baud rate (bps) for all VP44 communications.</summary>
private const int KLineBaudRate = 9600;
private readonly IAppLogger _log;
private const string LogId = "KwpService";
// ── Events ────────────────────────────────────────────────────────────────
/// <inheritdoc/>
public event Action<int, string>? ProgressChanged;
/// <inheritdoc/>
public event Action? PumpDisconnectRequested;
/// <inheritdoc/>
public event Action? PumpReconnectRequested;
// ── Constructor ───────────────────────────────────────────────────────────
/// <param name="logger">Application logger.</param>
public KwpService(IAppLogger logger)
{
_log = logger;
}
// ── IKwpService: full read ────────────────────────────────────────────────
/// <inheritdoc/>
public async Task<Dictionary<string, string>> ReadAllInfoAsync(
string port, int pumpVersion, CancellationToken ct = default)
{
return await Task.Run(() => ReadAllInfo(port, pumpVersion, ct), ct);
}
private Dictionary<string, string> ReadAllInfo(string port, int pumpVersion, CancellationToken ct)
{
var result = new Dictionary<string, string> { [KlineKeys.Result] = "0" };
FtdiInterface? iface = null;
try
{
Report(10, "Connecting to K-Line interface...");
iface = new FtdiInterface(port, KLineBaudRate);
ct.ThrowIfCancellationRequested();
var kwpCommon = new KwpCommon(iface);
kwpCommon.WakeUp(EcuInitAddress);
var kwp = new KW1281Connection(kwpCommon);
Report(20, "Connected. Reading ECU identification...");
var ecuInfo = pumpVersion == 2
? kwp.ReadEcuInfoCustom(5)
: kwp.ReadEcuInfo();
// ECU text layout (each field is 10 chars, positions are 0-based):
// 0-11 Model Reference
// 12-21 Data Record
// 22-31 SW Version 1
// 32-41 SW Version 2 (pump v2+)
// 42-51 Pump Control (pump v2+)
string text = ecuInfo.Text;
result[KlineKeys.ModelReference] = SafeSubstring(text, 0, 12).Trim();
result[KlineKeys.DataRecord] = SafeSubstring(text, 12, 10).Trim();
result[KlineKeys.SwVersion1] = SafeSubstring(text, 22, 10).Trim();
if (text.Length > 40) result[KlineKeys.SwVersion2] = SafeSubstring(text, 32, 10).Trim();
if (text.Length > 50) result[KlineKeys.PumpControl] = SafeSubstring(text, 42, 10).Trim();
// Read diagnostic trouble codes.
kwp.KeepAlive();
Report(30, "Reading fault codes...");
var faultCodes = kwp.ReadFaultCodes();
result[KlineKeys.Errors] = faultCodes.Count > 0
? string.Join(Environment.NewLine, faultCodes)
: KlineKeys.NoErrors;
ct.ThrowIfCancellationRequested();
// Unlock EEPROM for the given pump variant.
if (pumpVersion == 2)
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 });
Report(40, "Reading DFI calibration value...");
kwp.KeepAlive();
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
// EEPROM address 0x0044 holds the signed DFI byte.
// DFI (degrees) = (signed_byte × 3) / 256
var dfiPackets = kwp.SendCustom(new List<byte> { 0x19, 0x02, 0x00, 0x44 });
double dfi = 0;
foreach (var pkt in dfiPackets)
{
if (pkt is ReadEepromResponsePacket && pkt.Body.Count > 0)
{ dfi = ((sbyte)pkt.Body[0] * 3.0) / 256.0; break; }
}
result[KlineKeys.Dfi] = dfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
// Version-specific session unlock.
kwp.KeepAlive();
switch (pumpVersion)
{
case 0: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0x82, 0x33 }); break;
case 1: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x72, 0x53 }); break;
case 2: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 }); break;
}
Report(60, "Reading customer change index...");
kwp.KeepAlive();
ushort custChangeAddr = ReadCustomerChangeAddress(kwp, pumpVersion);
string custChangeIndex = ReadRomString(kwp, custChangeAddr, 6);
Report(80, "Reading pump identifier...");
kwp.KeepAlive();
ushort identAddr = ReadIdentAddress(kwp);
string ident = identAddr != 0 ? ReadRomString(kwp, identAddr, 10) : string.Empty;
Report(90, "Reading serial number...");
kwp.KeepAlive();
// EEPROM 0x0080, 6 bytes = ASCII serial number
string serial = ReadEepromString(kwp, new List<byte> { 0x19, 0x06, 0x00, 0x80 });
result[KlineKeys.PumpId] = ident;
result[KlineKeys.SerialNumber] = serial;
result[KlineKeys.ModelIndex] = custChangeIndex;
Report(95, "Enabling signal and closing session...");
kwp.KeepAlive();
kwp.SendCustom(new List<byte> { 0x00 });
if (pumpVersion != 2)
{
kwp.SendCustom(new List<byte> { 0x02, 0x88, 0x01, 0x04, 0x06, 0x01 });
}
else
{
kwp.SendCustom(new List<byte> { 0x02, 0x55, 0x01, 0x04, 0x06, 0x01 });
kwp.SendCustom(new List<byte> { 0x01, 0x02, 0x00, 0xC6 });
for (int i = 0; i < 10; i++) kwp.KeepAlive();
}
kwp.KeepAlive();
kwp.EndCommunication();
result[KlineKeys.Result] = "1";
}
catch (OperationCanceledException)
{
result[KlineKeys.ConnectError] = "Cancelled";
}
catch (Exception ex)
{
result[KlineKeys.ConnectError] = ex.Message;
_log.Error(LogId, $"ReadAllInfo exception: {ex}");
}
finally
{
iface?.Dispose();
}
return result;
}
// ── IKwpService: DTC operations ───────────────────────────────────────────
/// <inheritdoc/>
public async Task<string> ReadFaultCodesAsync(string port, CancellationToken ct = default)
{
return await Task.Run(() =>
{
FtdiInterface? iface = null;
try
{
Report(25, "Connecting...");
iface = new FtdiInterface(port, KLineBaudRate);
var kwpCommon1 = new KwpCommon(iface);
kwpCommon1.WakeUp(EcuInitAddress);
var kwp = new KW1281Connection(kwpCommon1);
kwp.ReadEcuInfo();
kwp.KeepAlive();
Report(75, "Reading fault codes...");
var codes = kwp.ReadFaultCodes();
kwp.KeepAlive();
kwp.EndCommunication();
Report(100, "Done.");
return codes.Count > 0
? string.Join(Environment.NewLine, codes)
: KlineKeys.NoErrors;
}
catch (Exception ex)
{
_log.Error(LogId, $"ReadFaultCodes: {ex.Message}");
return $"Error: {ex.Message}";
}
finally { iface?.Dispose(); }
}, ct);
}
/// <inheritdoc/>
public async Task<string> ClearFaultCodesAsync(string port, CancellationToken ct = default)
{
return await Task.Run(() =>
{
FtdiInterface? iface = null;
try
{
Report(25, "Connecting...");
iface = new FtdiInterface(port, KLineBaudRate);
var kwpCommon2 = new KwpCommon(iface);
kwpCommon2.WakeUp(EcuInitAddress);
var kwp = new KW1281Connection(kwpCommon2);
kwp.ReadEcuInfo();
kwp.KeepAlive();
Report(60, "Clearing fault codes...");
kwp.ClearFaultCodes();
kwp.KeepAlive();
var codes = kwp.ReadFaultCodes();
kwp.KeepAlive();
kwp.EndCommunication();
Report(100, "Done.");
return codes.Count > 0
? string.Join(Environment.NewLine, codes)
: KlineKeys.NoErrors;
}
catch (Exception ex)
{
_log.Error(LogId, $"ClearFaultCodes: {ex.Message}");
return $"Error: {ex.Message}";
}
finally { iface?.Dispose(); }
}, ct);
}
// ── IKwpService: DFI operations ───────────────────────────────────────────
/// <inheritdoc/>
public async Task<string> ReadDfiAsync(string port, CancellationToken ct = default)
{
return await Task.Run(() =>
{
FtdiInterface? iface = null;
try
{
Report(15, "Connecting...");
iface = new FtdiInterface(port, KLineBaudRate);
var kwpCommon3 = new KwpCommon(iface);
kwpCommon3.WakeUp(EcuInitAddress);
var kwp = new KW1281Connection(kwpCommon3);
Report(45, "Reading ECU info...");
kwp.ReadEcuInfo();
kwp.KeepAlive();
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
var packets = kwp.SendCustom(new List<byte> { 0x19, 0x02, 0x00, 0x44 });
double dfi = 0;
foreach (var pkt in packets)
if (pkt is ReadEepromResponsePacket && pkt.Body.Count > 0)
{ dfi = ((sbyte)pkt.Body[0] * 3.0) / 256.0; break; }
Report(95, "Closing session...");
kwp.KeepAlive();
kwp.EndCommunication();
Report(100, "Done.");
return dfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_log.Error(LogId, $"ReadDfi: {ex.Message}");
return "0";
}
finally { iface?.Dispose(); }
}, ct);
}
/// <inheritdoc/>
public async Task<string> WriteDfiAsync(string port, float dfi, int version, CancellationToken ct = default)
{
return await Task.Run(() => WriteDfiInternal(port, dfi, version, closeSession: true), ct);
}
/// <inheritdoc/>
public async Task<string> WriteDfiAndRestartAsync(string port, float dfi, int version, CancellationToken ct = default)
{
var result = await Task.Run(() => WriteDfiInternal(port, dfi, version, closeSession: true), ct);
PumpDisconnectRequested?.Invoke();
await Task.Delay(1000, ct);
PumpReconnectRequested?.Invoke();
return result;
}
// ── IKwpService: device detection ────────────────────────────────────────
/// <inheritdoc/>
public string? DetectKLinePort()
{
try
{
uint count = FtdiInterface.GetDevicesCount();
_log.Info(LogId, $"FTDI device count: {count}");
if (count == 0) return null;
var list = new FT_DEVICE_INFO_NODE[count];
FtdiInterface.GetDeviceList(list);
var serial = list[0].SerialNumber;
_log.Info(LogId, $"Selected FTDI device: Serial={serial}, Desc={list[0].Description}");
return serial;
}
catch (Exception ex)
{
_log.Warning(LogId, $"DetectKLinePort: {ex.Message}");
return null;
}
}
// ── Private helpers ───────────────────────────────────────────────────────
private string WriteDfiInternal(string port, float dfi, int version, bool closeSession)
{
FtdiInterface? iface = null;
double newDfi = 0;
try
{
Report(10, "Connecting...");
iface = new FtdiInterface(port, KLineBaudRate);
var kwpCommon4 = new KwpCommon(iface);
kwpCommon4.WakeUp(EcuInitAddress);
var kwp = new KW1281Connection(kwpCommon4);
Report(30, "Reading ECU info...");
kwp.ReadEcuInfo();
kwp.KeepAlive();
// Select the correct authentication password packet for the pump version.
// These byte sequences were established by reverse engineering the original firmware.
var passPacket = version switch
{
//1 => new List<byte> { 0x18, 0x00, 0x03, 0x2F, 0xF2, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 },
1 => new List<byte> { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x30, 0x35, 0x30, 0x30, 0x30, 0x31, 0x1C, 0x09, 0x04 },
2 or 3 => new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xF2, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 },
_ => new List<byte> { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 } // V1
};
Report(50, "Authenticating and writing DFI...");
kwp.SendCustom(passPacket);
kwp.KeepAlive();
// Encode DFI: signed_byte = (dfi × 256) / 3
// A zero raw byte is not accepted by the ECU — use 1 instead.
sbyte rawValue = (sbyte)((dfi * 256.0f) / 3.0f);
if (rawValue == 0) rawValue = 1;
byte checksum = (byte)(0 - (byte)rawValue); // one's complement checksum
var returnpacket = kwp.SendCustom(new List<byte> { 0x1A, 0x02, 0x00, 0x44, (byte)rawValue, checksum, 0x03 });
kwp.KeepAlive(); //2 0 68 255 2 0 44 ff
Report(60, "Verifying write...");
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
var packets = kwp.SendCustom(new List<byte> { 0x19, 0x02, 0x00, 0x44 });
foreach (var pkt in packets)
if (pkt is ReadEepromResponsePacket && pkt.Body.Count > 0)
{ newDfi = ((sbyte)pkt.Body[0] * 3.0) / 256.0; break; }
Report(70, "Closing session...");
kwp.KeepAlive();
if (closeSession) kwp.EndCommunication();
}
catch (Exception ex)
{
_log.Error(LogId, $"WriteDfi: {ex.Message}");
}
finally
{
iface?.Dispose();
}
return newDfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
private ushort ReadCustomerChangeAddress(KW1281Connection kwp, int pumpVersion)
{
if (pumpVersion == 2)
{
var packets = kwp.SendCustom(new List<byte> { 0x01, 0x02, 0x00, 0xC6 });
foreach (var pkt in packets)
if (pkt.Body.Count > 1)
return (ushort)(((pkt.Body[1] << 8) | pkt.Body[0]) - 0x1D);
return 0;
}
else
{
var data = kwp.ReadRomEeprom(0x9FFE, 2);
if (data == null || data.Count < 2) return 0;
return (ushort)(((data[1] << 8) | data[0]) + 3);
}
}
private ushort ReadIdentAddress(KW1281Connection kwp)
{
var packets = kwp.SendCustom(new List<byte> { 0x01, 0x02, 0x00, 0xC6 });
foreach (var pkt in packets)
if (pkt.Body.Count > 1)
return (ushort)(((pkt.Body[1] << 8) | pkt.Body[0]) - 10);
return 0;
}
private string ReadRomString(KW1281Connection kwp, ushort address, byte count)
{
var data = kwp.ReadRomEeprom(address, count);
if (data == null || data.Count == 0) return string.Empty;
var sb = new System.Text.StringBuilder();
foreach (var b in data) sb.Append(Convert.ToChar(b));
return sb.ToString();
}
private string ReadEepromString(KW1281Connection kwp, List<byte> command)
{
var packets = kwp.SendCustom(command);
foreach (var pkt in packets)
{
if (pkt is ReadEepromResponsePacket)
{
var sb = new System.Text.StringBuilder();
foreach (var b in pkt.Body) sb.Append(Convert.ToChar(b));
return sb.ToString();
}
}
return string.Empty;
}
private static string SafeSubstring(string s, int start, int length)
{
if (s.Length <= start) return string.Empty;
int avail = Math.Min(length, s.Length - start);
return s.Substring(start, avail);
}
private void Report(int percent, string message)
=> ProgressChanged?.Invoke(percent, message);
}
}

297
Services/Impl/PdfService.cs Normal file
View File

@@ -0,0 +1,297 @@
using System;
using System.IO;
using HC_APTBS.Models;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Generates PDF test reports using QuestPDF.
///
/// <para>
/// Report layout:
/// <list type="bullet">
/// <item>Header: company logo, company name, date, operator, client.</item>
/// <item>Pump identification table: ID, serial, model, rotation, lock angle.</item>
/// <item>K-Line ECU data block: model reference, DFI, SW versions, fault codes.</item>
/// <item>Per-test results section: one table per enabled phase showing measured
/// average vs. target ± tolerance and a pass/fail indicator.</item>
/// <item>Footer: page numbers.</item>
/// </list>
/// </para>
/// </summary>
public sealed class PdfService : IPdfService
{
private readonly IConfigurationService _config;
/// <param name="configService">Provides company name, logo path, and report settings.</param>
public PdfService(IConfigurationService configService)
{
_config = configService;
// QuestPDF community licence — required for open-source use.
QuestPDF.Settings.License = LicenseType.Community;
}
// ── IPdfService ───────────────────────────────────────────────────────────
/// <inheritdoc/>
public string GenerateReport(
PumpDefinition pump,
string operatorName,
string clientName,
string outputFolder)
{
Directory.CreateDirectory(outputFolder);
string fileName = SanitiseFileName(
$"{pump.Id}_{pump.SerialNumber}_{clientName}_{DateTime.Now:yyyy-MM-dd_HH-mm}.pdf");
string filePath = Path.Combine(outputFolder, fileName);
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(25, Unit.Millimetre);
page.DefaultTextStyle(x => x.FontSize(9).FontFamily(Fonts.Arial));
page.Header().Element(c => ComposeHeader(c, pump, operatorName, clientName));
page.Content().Element(c => ComposeContent(c, pump));
page.Footer().AlignCenter().Text(t =>
{
t.Span("Page ");
t.CurrentPageNumber();
t.Span(" of ");
t.TotalPages();
});
});
});
document.GeneratePdf(filePath);
return filePath;
}
// ── Header ────────────────────────────────────────────────────────────────
private void ComposeHeader(
IContainer container, PumpDefinition pump, string operatorName, string clientName)
{
container.Row(row =>
{
// Company logo (optional)
if (File.Exists(_config.Settings.ReportLogoPath))
{
row.ConstantItem(60).Height(40)
.Image(_config.Settings.ReportLogoPath)
.FitArea();
}
row.RelativeItem().PaddingLeft(10).Column(col =>
{
col.Item().Text(_config.Settings.CompanyName)
.Bold().FontSize(14);
col.Item().Text(_config.Settings.CompanyInfo)
.FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(130).Column(col =>
{
col.Item().Text($"Date: {DateTime.Now:dd/MM/yyyy HH:mm}").FontSize(8);
col.Item().Text($"Operator: {operatorName}").FontSize(8);
col.Item().Text($"Client: {clientName}").FontSize(8);
});
});
}
// ── Content ───────────────────────────────────────────────────────────────
private static void ComposeContent(IContainer container, PumpDefinition pump)
{
container.PaddingTop(10).Column(col =>
{
// ── Pump identification ──────────────────────────────────────────
col.Item().PaddingBottom(8).Element(c => ComposePumpInfoTable(c, pump));
// ── K-Line ECU data ──────────────────────────────────────────────
if (pump.KlineInfo.Count > 0)
col.Item().PaddingBottom(8).Element(c => ComposeKlineTable(c, pump));
// ── Test results — one section per test ──────────────────────────
foreach (var test in pump.Tests)
{
if (!test.HasResults()) continue;
col.Item().PaddingBottom(6).Element(c => ComposeTestSection(c, test));
}
});
}
// ── Pump info table ───────────────────────────────────────────────────────
private static void ComposePumpInfoTable(IContainer container, PumpDefinition pump)
{
container.Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.RelativeColumn(2);
cols.RelativeColumn(1);
cols.RelativeColumn(2);
});
table.Header(header =>
{
header.Cell().ColumnSpan(4)
.Background(Colors.Blue.Darken3)
.Padding(4)
.Text("PUMP IDENTIFICATION")
.FontColor(Colors.White).Bold().FontSize(10);
});
void AddRow(string label1, string value1, string label2, string value2)
{
table.Cell().Padding(3).Text(label1).Bold();
table.Cell().Padding(3).Text(value1);
table.Cell().Padding(3).Text(label2).Bold();
table.Cell().Padding(3).Text(value2);
}
AddRow("Pump ID:", pump.Id, "Model:", pump.Model);
AddRow("Serial No.:", pump.SerialNumber, "Injector:", pump.Injector);
AddRow("Tube:", pump.Tube, "Valve:", pump.Valve);
AddRow("Tension:", pump.Tension, "Rotation:", pump.Rotation);
AddRow("Lock Angle:", $"{pump.LockAngle:F2}°",
"Measured:", $"{pump.LockAngleResult:F2}°");
AddRow("Chaveta:", pump.Chaveta, "Pre-Inj.:", pump.HasPreInjection ? "Yes" : "No");
});
}
// ── K-Line table ──────────────────────────────────────────────────────────
private static void ComposeKlineTable(IContainer container, PumpDefinition pump)
{
container.Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.RelativeColumn(2);
cols.RelativeColumn(1);
cols.RelativeColumn(2);
});
table.Header(header =>
{
header.Cell().ColumnSpan(4)
.Background(Colors.Blue.Darken2)
.Padding(4)
.Text("ECU DATA (K-Line)")
.FontColor(Colors.White).Bold().FontSize(10);
});
void AddKv(string key)
{
if (pump.KlineInfo.TryGetValue(key, out var val))
{
table.Cell().Padding(3).Text(key + ":").Bold();
table.Cell().ColumnSpan(3).Padding(3).Text(val);
}
}
AddKv(KlineKeys.ModelReference);
AddKv(KlineKeys.DataRecord);
AddKv(KlineKeys.SwVersion1);
AddKv(KlineKeys.SwVersion2);
AddKv(KlineKeys.PumpControl);
AddKv(KlineKeys.Dfi);
AddKv(KlineKeys.SerialNumber);
AddKv(KlineKeys.Errors);
});
}
// ── Test results section ──────────────────────────────────────────────────
private static void ComposeTestSection(IContainer container, TestDefinition test)
{
container.Column(col =>
{
col.Item()
.Background(Colors.Grey.Lighten2)
.Padding(4)
.Text($"TEST: {test.Name}")
.Bold().FontSize(10);
col.Item().Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2); // Phase
cols.RelativeColumn(1); // Parameter
cols.RelativeColumn(1); // Target
cols.RelativeColumn(1); // Tolerance
cols.RelativeColumn(1); // Average
cols.RelativeColumn(1); // Result
});
table.Header(header =>
{
foreach (var h in new[] { "Phase", "Parameter", "Target", "Tolerance ±", "Average", "Result" })
header.Cell()
.Background(Colors.Grey.Darken1)
.Padding(3)
.Text(h).FontColor(Colors.White).Bold().FontSize(8);
});
foreach (var phase in test.Phases)
{
if (!phase.Enabled || phase.Receives == null) continue;
foreach (var tp in phase.Receives)
{
if (tp.Result == null) continue;
bool passed = tp.Result.Passed;
string resultText = passed ? "PASS" : "FAIL";
string bgColor = passed ? Colors.Green.Lighten4 : Colors.Red.Lighten4;
table.Cell().Background(bgColor).Padding(3).Text(phase.Name).FontSize(8);
table.Cell().Background(bgColor).Padding(3).Text(tp.Name).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(tp.Value.ToString("F2")).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(tp.Tolerance.ToString("F2")).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(tp.Result.Average.ToString("F2")).FontSize(8);
table.Cell().Background(bgColor).Padding(3)
.Text(resultText).Bold()
.FontColor(passed ? Colors.Green.Darken2 : Colors.Red.Darken2)
.FontSize(8);
}
// Show any alarm bits that fired during this phase.
if (phase.ErrorBits?.Count > 0)
{
table.Cell().ColumnSpan(6)
.Background(Colors.Orange.Lighten4)
.Padding(3)
.Text($" ⚠ Error bits: {string.Join(", ", phase.ErrorBits)}")
.FontSize(8).FontColor(Colors.Orange.Darken3);
}
}
});
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string SanitiseFileName(string name)
{
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name;
}
}
}

View File

@@ -0,0 +1,201 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using HC_APTBS.Models;
namespace HC_APTBS.Services.Impl
{
/// <summary>
/// Implements the immobilizer unlock sequence for Ford VP44 pump ECUs.
/// The unlock has two phases:
/// <list type="number">
/// <item>Continuous CAN message sends for ~10 minutes (600.5 s)</item>
/// <item>A state-machine handshake that cycles through command bytes on 0x700</item>
/// </list>
/// </summary>
public sealed class UnlockService : IUnlockService
{
private readonly ICanService _can;
private readonly IAppLogger _log;
private const string LogId = "UnlockService";
/// <summary>Total duration of the Phase 1 continuous send (milliseconds).</summary>
private const int UnlockDurationMs = 600_500;
/// <inheritdoc/>
public event Action<string>? StatusChanged;
/// <inheritdoc/>
public event Action<bool>? UnlockCompleted;
/// <summary>Creates the unlock service wired to the CAN bus.</summary>
public UnlockService(ICanService canService, IAppLogger logger)
{
_can = canService;
_log = logger;
}
/// <inheritdoc/>
public async Task UnlockAsync(PumpDefinition pump, CancellationToken ct)
{
if (pump.UnlockType == 0) return;
_log.Info(LogId, $"Starting immobilizer unlock (type {pump.UnlockType}) for {pump.Id}");
StatusChanged?.Invoke("Unlocking...");
// ── Phase 1: Continuous sends for ~10 minutes ─────────────────────────
await RunPhase1Async(pump.UnlockType, ct);
ct.ThrowIfCancellationRequested();
// ── Phase 2: TestUnlock state machine ─────────────────────────────────
StatusChanged?.Invoke("Testing unlock...");
RunTestUnlockSequence(pump.UnlockType);
// ── Verify unlock status ──────────────────────────────────────────────
bool success = VerifyUnlock(pump);
_log.Info(LogId, $"Unlock complete — success={success}");
StatusChanged?.Invoke(success ? "Unlocked" : "Unlock failed");
UnlockCompleted?.Invoke(success);
}
// ── Phase 1 ──────────────────────────────────────────────────────────────
private async Task RunPhase1Async(int unlockType, CancellationToken ct)
{
// Build message payloads based on unlock type.
byte[] msg1Data = new byte[8];
uint msg1Id;
byte[] msg2Data = new byte[8];
uint msg2Id;
switch (unlockType)
{
case 1:
msg1Id = 0x700;
msg1Data[0] = 0xB2;
msg2Id = 0x300;
msg2Data[0] = 0x01; msg2Data[1] = 0x48;
msg2Data[2] = 0x50; msg2Data[3] = 0xC3;
break;
case 2:
msg1Id = 0x700;
msg1Data[3] = 0xB2;
msg2Id = 0x500;
msg2Data[4] = 0x78;
break;
default:
return;
}
// Run two parallel senders for the full unlock duration.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(UnlockDurationMs);
var linkedCt = cts.Token;
var sender1 = Task.Run(async () =>
{
try
{
while (!linkedCt.IsCancellationRequested)
{
_can.SendRawMessage(msg1Id, msg1Data);
await Task.Delay(500, linkedCt);
}
}
catch (OperationCanceledException) { }
}, linkedCt);
var sender2 = Task.Run(async () =>
{
try
{
while (!linkedCt.IsCancellationRequested)
{
_can.SendRawMessage(msg2Id, msg2Data);
await Task.Delay(50, linkedCt);
}
}
catch (OperationCanceledException) { }
}, linkedCt);
// Report progress periodically.
var progressTask = Task.Run(async () =>
{
var start = DateTime.UtcNow;
try
{
while (!linkedCt.IsCancellationRequested)
{
await Task.Delay(1000, linkedCt);
var elapsed = DateTime.UtcNow - start;
int pct = (int)(elapsed.TotalMilliseconds * 100 / UnlockDurationMs);
string time = $"{(int)elapsed.TotalMinutes:D2}:{elapsed.Seconds:D2}";
StatusChanged?.Invoke($"Unlocking... {Math.Min(pct, 100)}% ({time})");
}
}
catch (OperationCanceledException) { }
}, linkedCt);
await Task.WhenAll(sender1, sender2, progressTask);
// If the outer ct was cancelled (user stop), propagate.
ct.ThrowIfCancellationRequested();
}
// ── Phase 2: TestUnlock state machine ────────────────────────────────────
private void RunTestUnlockSequence(int unlockType)
{
// The state machine cycles through 4 command bytes, twice.
byte[][] type1Cmds =
{
new byte[] { 0xB2, 0, 0, 0, 0, 0, 0, 0 },
new byte[] { 0xB6, 0, 0, 0, 0, 0, 0, 0 },
new byte[] { 0x23, 0, 0, 0, 0, 0, 0, 0 },
new byte[] { 0x24, 0, 0, 0, 0, 0, 0, 0 }
};
byte[][] type2Cmds =
{
new byte[] { 0, 0, 0, 0xB2, 0, 0, 0, 0 },
new byte[] { 0, 0, 0, 0x24, 0, 0, 0, 0 },
new byte[] { 0, 0, 0, 0x24, 0, 0, 0, 0 },
new byte[] { 0, 0, 0, 0x24, 0, 0, 0, 0 }
};
byte[][] cmds = unlockType == 1 ? type1Cmds : type2Cmds;
for (int loop = 0; loop < 2; loop++)
{
for (int step = 0; step < cmds.Length; step++)
{
_can.SendRawMessage(0x700, cmds[step]);
Thread.Sleep(500);
}
}
StatusChanged?.Invoke("Sending...");
}
// ── Verification ─────────────────────────────────────────────────────────
private bool VerifyUnlock(PumpDefinition pump)
{
if (!pump.ParametersByName.TryGetValue(PumpParameterNames.TestUnlock, out var unlockParam))
return false;
switch (pump.UnlockType)
{
case 1:
// Type 1: unlocked when TestUnlock value is non-zero.
return unlockParam.Value != 0;
case 2:
// Type 2: unlocked when TestUnlock value equals 0xE4 (228).
return (int)unlockParam.Value == 0xE4;
default:
return false;
}
}
}
}