initial commit
This commit is contained in:
23
Services/IAppLogger.cs
Normal file
23
Services/IAppLogger.cs
Normal 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
181
Services/IBenchService.cs
Normal 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
|
||||
}
|
||||
}
|
||||
36
Services/ICalibrationService.cs
Normal file
36
Services/ICalibrationService.cs
Normal 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
77
Services/ICanService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
66
Services/IConfigurationService.cs
Normal file
66
Services/IConfigurationService.cs
Normal 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
101
Services/IKwpService.cs
Normal 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
25
Services/IPdfService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
Services/IUnlockService.cs
Normal file
28
Services/IUnlockService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
751
Services/Impl/BenchService.cs
Normal file
751
Services/Impl/BenchService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
Services/Impl/CalibrationService.cs
Normal file
57
Services/Impl/CalibrationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
693
Services/Impl/ConfigurationService.cs
Normal file
693
Services/Impl/ConfigurationService.cs
Normal 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
463
Services/Impl/KwpService.cs
Normal 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
297
Services/Impl/PdfService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
201
Services/Impl/UnlockService.cs
Normal file
201
Services/Impl/UnlockService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user