using System;
using System.Collections.Generic;
using System.Linq;
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
{
///
/// Implements using the FTDI USB-to-K-Line adapter
/// and the KW1281 protocol stack from .
///
///
/// The ECU initialisation address for all VP44 pumps is 0xF1 (broadcast).
/// K-Line baud rate is 9600 bps.
///
///
public sealed class KwpService : IKwpService, IDisposable
{
// ── Protocol constants ────────────────────────────────────────────────────
/// ECU initialisation address used in the 5-baud wake-up sequence.
private const byte EcuInitAddress = 0xF1;
/// K-Line baud rate (bps) for all VP44 communications.
private const int KLineBaudRate = 9600;
/// Interval between keep-alive ACK packets (ms).
private const int KeepAliveIntervalMs = 1000;
private readonly IAppLogger _log;
private const string LogId = "KwpService";
// ── Persistent session fields ─────────────────────────────────────────────
private FtdiInterface? _sessionIface;
private KwpCommon? _sessionKwpCommon;
private KW1281Connection? _sessionKwp;
private string? _connectedPort;
// ── Synchronization ───────────────────────────────────────────────────────
private readonly SemaphoreSlim _busLock = new(1, 1);
private CancellationTokenSource? _keepAliveCts;
private Task? _keepAliveTask;
// ── Events ────────────────────────────────────────────────────────────────
///
public event Action? ProgressChanged;
///
public event Action? PumpIdentified;
///
public event Action? DfiRead;
///
public event Action? BipStatusRead;
///
public event Action? PumpDisconnectRequested;
///
public event Action? PumpReconnectRequested;
///
public event Action? KLineStateChanged;
// ── Session state ─────────────────────────────────────────────────────────
private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
///
public KLineConnectionState KLineState => _kLineState;
///
public string? ConnectedPort => _connectedPort;
// ── Constructor ───────────────────────────────────────────────────────────
/// Application logger.
public KwpService(IAppLogger logger)
{
_log = logger;
}
// ── IKwpService: session lifecycle ────────────────────────────────────────
///
public async Task ConnectAsync(string port, CancellationToken ct = default)
{
if (_kLineState == KLineConnectionState.Connected)
throw new InvalidOperationException("K-Line session is already active. Disconnect first.");
await Task.Run(() =>
{
Report(10, "Connecting to K-Line interface...");
var iface = new FtdiInterface(port, KLineBaudRate);
try
{
ct.ThrowIfCancellationRequested();
var kwpCommon = new KwpCommon(iface);
kwpCommon.WakeUp(EcuInitAddress);
var kwp = new KW1281Connection(kwpCommon);
Report(50, "Reading ECU identification...");
kwp.ReadEcuInfo();
// Store session objects.
_sessionIface = iface;
_sessionKwpCommon = kwpCommon;
_sessionKwp = kwp;
_connectedPort = port;
Report(100, "K-Line session established.");
_log.Info(LogId, $"Persistent session opened on {port}");
}
catch
{
iface.Dispose();
throw;
}
}, ct);
SetState(KLineConnectionState.Connected);
StartKeepAlive();
}
///
public void Disconnect()
{
StopKeepAlive();
_busLock.Wait();
try
{
if (_sessionKwp != null)
{
try { _sessionKwp.EndCommunication(); }
catch (Exception ex) { _log.Warning(LogId, $"EndCommunication on disconnect: {ex.Message}"); }
}
CleanupSession();
}
finally
{
_busLock.Release();
}
SetState(KLineConnectionState.Disconnected);
_log.Info(LogId, "Persistent session disconnected.");
}
///
public void Dispose()
{
StopKeepAlive();
CleanupSession();
_busLock.Dispose();
}
// ── IKwpService: full read ────────────────────────────────────────────────
///
public async Task> ReadAllInfoAsync(
string port, int pumpVersion, CancellationToken ct = default)
{
// If a persistent session is already active, reuse it —
// skip the slow WakeUp + ReadEcuInfo and keep the session alive afterward.
if (_kLineState == KLineConnectionState.Connected)
return await Task.Run(() => ReadAllInfoWithSession(pumpVersion, ct), ct);
var result = await Task.Run(() => ReadAllInfo(port, pumpVersion, ct), ct);
// On a successful fresh read, promote the transient session to a
// persistent one and start the keep-alive loop so the indicator
// turns green and subsequent operations can reuse the connection.
if (_sessionKwp != null)
{
SetState(KLineConnectionState.Connected);
StartKeepAlive();
}
return result;
}
private Dictionary ReadAllInfo(string port, int pumpVersion, CancellationToken ct)
{
var result = new Dictionary { [KlineKeys.Result] = "0" };
FtdiInterface? iface = null;
bool promoteSession = false;
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();
ct.ThrowIfCancellationRequested();
// Unlock EEPROM for the given pump variant.
if (pumpVersion == 2)
kwp.SendCustom(new List { 0x18, 0x00, 0x01, 0x53, 0x72 });
// Version-specific session unlock — moved before ROM reads so the
// pump identifier can be obtained as early as possible.
kwp.KeepAlive();
switch (pumpVersion)
{
case 0: kwp.SendCustom(new List { 0x18, 0x00, 0x00, 0x82, 0x33 }); break;
case 1: kwp.SendCustom(new List { 0x18, 0x00, 0x01, 0x72, 0x53 }); break;
case 2: kwp.SendCustom(new List { 0x18, 0x00, 0x01, 0x53, 0x72 }); break;
}
// Read the ROM base address once (0xC6 command). Both the pump
// identifier and the V2 customer-change index derive from it.
Report(40, "Reading pump identifier...");
kwp.KeepAlive();
ushort baseAddr = ReadBaseRomAddress(kwp);
ushort identAddr = (ushort)(baseAddr >= 10 ? baseAddr - 10 : 0);
string ident = identAddr != 0 ? ReadRomString(kwp, identAddr, 10) : string.Empty;
result[KlineKeys.PumpId] = ident;
// Notify subscribers immediately so the pump definition and its
// tests can start loading while the K-Line read continues.
if (!string.IsNullOrEmpty(ident))
{
_log.Info(LogId, $"PumpIdentified fired: '{ident}'");
PumpIdentified?.Invoke(ident);
}
Report(55, "Reading customer change index...");
kwp.KeepAlive();
ushort custChangeAddr;
if (pumpVersion == 2)
{
// Reuse the base address from the 0xC6 response.
custChangeAddr = (ushort)(baseAddr >= 0x1D ? baseAddr - 0x1D : 0);
}
else
{
custChangeAddr = ReadCustomerChangeAddressNonV2(kwp);
}
string custChangeIndex = custChangeAddr != 0
? ReadRomString(kwp, custChangeAddr, 6)
: string.Empty;
result[KlineKeys.ModelIndex] = custChangeIndex;
Report(65, "Reading DFI calibration value...");
kwp.KeepAlive();
kwp.SendCustom(new List { 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 { 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);
// Notify subscribers so the DFI slider updates in real time.
DfiRead?.Invoke(dfi);
Report(75, "Reading serial number...");
kwp.KeepAlive();
// EEPROM 0x0080, 6 bytes = ASCII serial number
string serial = ReadEepromString(kwp, new List { 0x19, 0x06, 0x00, 0x80 });
result[KlineKeys.SerialNumber] = serial;
Report(85, "Reading fault codes...");
kwp.KeepAlive();
var faultCodes = kwp.ReadFaultCodes();
result[KlineKeys.Errors] = faultCodes?.Count > 0
? string.Join(Environment.NewLine, faultCodes)
: KlineKeys.NoErrors;
Report(90, "Enabling signal...");
kwp.KeepAlive();
kwp.SendCustom(new List { 0x00 });
if (pumpVersion != 2)
{
kwp.SendCustom(new List { 0x02, 0x88, 0x01, 0x04, 0x06, 0x01 });
}
else
{
kwp.SendCustom(new List { 0x02, 0x55, 0x01, 0x04, 0x06, 0x01 });
kwp.SendCustom(new List { 0x01, 0x02, 0x00, 0xC6 });
for (int i = 0; i < 10; i++) kwp.KeepAlive();
}
kwp.KeepAlive();
// Promote the connection to a persistent session instead of
// closing it. The caller starts the keep-alive loop afterward.
_sessionIface = iface;
_sessionKwpCommon = kwpCommon;
_sessionKwp = kwp;
_connectedPort = port;
promoteSession = true;
result[KlineKeys.Result] = "1";
_log.Info(LogId, $"ReadAllInfo complete — session promoted to persistent on {port}");
}
catch (OperationCanceledException)
{
result[KlineKeys.ConnectError] = "Cancelled";
}
catch (Exception ex)
{
result[KlineKeys.ConnectError] = ex.Message;
_log.Error(LogId, $"ReadAllInfo exception: {ex}");
}
finally
{
// Only dispose if we did NOT promote the session.
if (!promoteSession)
iface?.Dispose();
}
return result;
}
///
/// Session-aware variant of . Reuses the persistent
/// K-Line session, skipping WakeUp and ReadEcuInfo. The session stays alive
/// afterward (no EndCommunication).
///
private Dictionary ReadAllInfoWithSession(int pumpVersion, CancellationToken ct)
{
var result = new Dictionary { [KlineKeys.Result] = "0" };
_busLock.Wait(ct);
try
{
var kwp = _sessionKwp!;
Report(20, "Reading pump data (session active)...");
ct.ThrowIfCancellationRequested();
// Unlock EEPROM for the given pump variant.
if (pumpVersion == 2)
kwp.SendCustom(new List { 0x18, 0x00, 0x01, 0x53, 0x72 });
// Version-specific session unlock.
kwp.KeepAlive();
switch (pumpVersion)
{
case 0: kwp.SendCustom(new List { 0x18, 0x00, 0x00, 0x82, 0x33 }); break;
case 1: kwp.SendCustom(new List { 0x18, 0x00, 0x01, 0x72, 0x53 }); break;
case 2: kwp.SendCustom(new List { 0x18, 0x00, 0x01, 0x53, 0x72 }); break;
}
// Read the ROM base address once (0xC6 command).
Report(40, "Reading pump identifier...");
kwp.KeepAlive();
ushort baseAddr = ReadBaseRomAddress(kwp);
ushort identAddr = (ushort)(baseAddr >= 10 ? baseAddr - 10 : 0);
string ident = identAddr != 0 ? ReadRomString(kwp, identAddr, 10) : string.Empty;
result[KlineKeys.PumpId] = ident;
if (!string.IsNullOrEmpty(ident))
{
_log.Info(LogId, $"PumpIdentified fired (session reuse): '{ident}'");
PumpIdentified?.Invoke(ident);
}
Report(55, "Reading customer change index...");
kwp.KeepAlive();
ushort custChangeAddr;
if (pumpVersion == 2)
{
custChangeAddr = (ushort)(baseAddr >= 0x1D ? baseAddr - 0x1D : 0);
}
else
{
custChangeAddr = ReadCustomerChangeAddressNonV2(kwp);
}
string custChangeIndex = custChangeAddr != 0
? ReadRomString(kwp, custChangeAddr, 6)
: string.Empty;
result[KlineKeys.ModelIndex] = custChangeIndex;
Report(65, "Reading DFI calibration value...");
kwp.KeepAlive();
kwp.SendCustom(new List { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
var dfiPackets = kwp.SendCustom(new List { 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);
DfiRead?.Invoke(dfi);
Report(75, "Reading serial number...");
kwp.KeepAlive();
string serial = ReadEepromString(kwp, new List { 0x19, 0x06, 0x00, 0x80 });
result[KlineKeys.SerialNumber] = serial;
Report(85, "Reading fault codes...");
kwp.KeepAlive();
var faultCodes = kwp.ReadFaultCodes();
result[KlineKeys.Errors] = faultCodes?.Count > 0
? string.Join(Environment.NewLine, faultCodes)
: KlineKeys.NoErrors;
Report(90, "Enabling signal...");
kwp.KeepAlive();
kwp.SendCustom(new List { 0x00 });
if (pumpVersion != 2)
{
kwp.SendCustom(new List { 0x02, 0x88, 0x01, 0x04, 0x06, 0x01 });
}
else
{
kwp.SendCustom(new List { 0x02, 0x55, 0x01, 0x04, 0x06, 0x01 });
kwp.SendCustom(new List { 0x01, 0x02, 0x00, 0xC6 });
for (int i = 0; i < 10; i++) kwp.KeepAlive();
}
kwp.KeepAlive();
// No EndCommunication — keep session alive.
result[KlineKeys.Result] = "1";
}
catch (OperationCanceledException)
{
result[KlineKeys.ConnectError] = "Cancelled";
}
catch (Exception ex)
{
result[KlineKeys.ConnectError] = ex.Message;
_log.Error(LogId, $"ReadAllInfo (session): {ex}");
CleanupSession();
SetState(KLineConnectionState.Failed);
}
finally
{
_busLock.Release();
}
return result;
}
// ── IKwpService: DTC operations ───────────────────────────────────────────
///
public async Task ReadFaultCodesAsync(string port, CancellationToken ct = default)
{
if (_kLineState == KLineConnectionState.Connected)
return await Task.Run(() => ReadFaultCodesWithSession(ct), ct);
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);
}
///
public async Task ClearFaultCodesAsync(string port, CancellationToken ct = default)
{
if (_kLineState == KLineConnectionState.Connected)
return await Task.Run(() => ClearFaultCodesWithSession(ct), ct);
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 ───────────────────────────────────────────
///
public async Task ReadDfiAsync(string port, CancellationToken ct = default)
{
if (_kLineState == KLineConnectionState.Connected)
return await Task.Run(() => ReadDfiWithSession(ct), ct);
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 { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
var packets = kwp.SendCustom(new List { 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);
}
///
public async Task WriteDfiAsync(string port, float dfi, int version, CancellationToken ct = default)
{
if (_kLineState == KLineConnectionState.Connected)
return await Task.Run(() => WriteDfiWithSession(dfi, version, ct), ct);
return await Task.Run(() => WriteDfiInternal(port, dfi, version, closeSession: true), ct);
}
///
public async Task WriteDfiAndRestartAsync(string port, float dfi, int version, CancellationToken ct = default)
{
string result;
if (_kLineState == KLineConnectionState.Connected)
{
result = await Task.Run(() => WriteDfiWithSession(dfi, version, ct), ct);
// Pump power will be cycled — the session is dead after this.
Disconnect();
}
else
{
result = await Task.Run(() => WriteDfiInternal(port, dfi, version, closeSession: true), ct);
}
PumpDisconnectRequested?.Invoke();
await Task.Delay(1000, ct);
PumpReconnectRequested?.Invoke();
return result;
}
// ── IKwpService: fast immobilizer unlock ──────────────────────────────────
///
public async Task TryFastUnlockAsync(int unlockType)
{
byte ramByte = unlockType switch
{
1 => 0xA8,
2 => 0xE8,
_ => 0x00
};
if (ramByte == 0x00)
{
_log.Info(LogId, $"TryFastUnlock: unsupported unlockType={unlockType} — skipping");
return false;
}
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, $"TryFastUnlock(type={unlockType}): no active K-Line session — skipping");
return false;
}
return await Task.Run(() =>
{
_busLock.Wait();
try
{
_log.Info(LogId, $"TryFastUnlock(type={unlockType}): sending unlock command (ram=0x{ramByte:X2}) over K-Line");
var packets = _sessionKwp!.SendCustom(
new List { 0x02, 0x88, 0x02, 0x03, ramByte, 0x01, 0x00 });
bool nak = packets.Count == 1
&& packets[0] is NakPacket;
_log.Info(LogId, $"TryFastUnlock(type={unlockType}): {(nak ? "NAK — pump rejected" : "ACK — pump unlocked")}");
return !nak;
}
catch (Exception ex)
{
_log.Warning(LogId, $"TryFastUnlock(type={unlockType}) failed: {ex.Message}");
return false;
}
finally
{
_busLock.Release();
}
});
}
// ── IKwpService: raw custom packet (developer tools) ──────────────────────
///
public async Task> SendRawCustomAsync(byte[] payload, CancellationToken ct = default)
{
if (payload is null || payload.Length == 0)
{
_log.Info(LogId, "SendRawCustom: empty payload — skipping");
return Array.Empty();
}
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "SendRawCustom: no active K-Line session — skipping");
return Array.Empty();
}
return await Task.Run>(() =>
{
_busLock.Wait(ct);
try
{
var hex = string.Join(" ", payload.Select(b => b.ToString("X2")));
_log.Info(LogId, $"SendRawCustom: TX {hex}");
var packets = _sessionKwp!.SendCustom(payload.ToList());
var response = packets.Select(p => p.Bytes.ToArray()).ToArray();
_log.Info(LogId, $"SendRawCustom: RX {response.Length} packet(s)");
return response;
}
catch (Exception ex)
{
_log.Warning(LogId, $"SendRawCustom failed: {ex.Message}");
return Array.Empty();
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: typed read primitives (developer tools) ──────────────────
///
public async Task> ReadEepromAsync(ushort address, byte count, CancellationToken ct = default)
{
if (count == 0) return Array.Empty();
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "ReadEeprom: no active K-Line session — skipping");
return Array.Empty();
}
return await Task.Run>(() =>
{
_busLock.Wait(ct);
try
{
var bytes = _sessionKwp!.ReadEeprom(address, count);
return bytes != null ? (IReadOnlyList)bytes : Array.Empty();
}
catch (Exception ex)
{
_log.Warning(LogId, $"ReadEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
return Array.Empty();
}
finally
{
_busLock.Release();
}
}, ct);
}
///
public async Task> ReadRomEepromAsync(ushort address, byte count, CancellationToken ct = default)
{
if (count == 0) return Array.Empty();
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "ReadRomEeprom: no active K-Line session — skipping");
return Array.Empty();
}
return await Task.Run>(() =>
{
_busLock.Wait(ct);
try
{
var bytes = _sessionKwp!.ReadRomEeprom(address, count);
return bytes != null ? (IReadOnlyList)bytes : Array.Empty();
}
catch (Exception ex)
{
_log.Warning(LogId, $"ReadRomEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
return Array.Empty();
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: BIP status ───────────────────────────────────────────────
///
public async Task ReadBipStatusAsync(CancellationToken ct = default)
{
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
return null;
return await Task.Run(() =>
{
_busLock.Wait(ct);
try
{
// ReadEeprom (0x19), 2 bytes, at RAM address 0x0106 (ADR-S_BIP_HW_UW).
// Byte order is little-endian, consistent with ReadCustomerChangeAddress.
var packets = _sessionKwp!.SendCustom(
new List { (byte)PacketCommand.ReadEeprom, 0x02, 0x01, 0x06 });
foreach (var pkt in packets)
{
if (pkt is ReadEepromResponsePacket && pkt.Body.Count >= 2)
{
ushort word = (ushort)((pkt.Body[1] << 8) | pkt.Body[0]);
_log.Debug(LogId, $"ReadBipStatus: 0x{word:X4}");
BipStatusRead?.Invoke(word);
return (ushort?)word;
}
}
_log.Warning(LogId, "ReadBipStatus: no ReadEepromResponse received");
return null;
}
catch (OperationCanceledException) { return null; }
catch (Exception ex)
{
_log.Warning(LogId, $"ReadBipStatus failed: {ex.Message}");
return null;
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: device detection ────────────────────────────────────────
///
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;
}
}
// ── Keep-alive loop ───────────────────────────────────────────────────────
private void StartKeepAlive()
{
_keepAliveCts = new CancellationTokenSource();
_keepAliveTask = Task.Run(() => KeepAliveLoop(_keepAliveCts.Token));
}
private void StopKeepAlive()
{
if (_keepAliveCts == null) return;
_keepAliveCts.Cancel();
try { _keepAliveTask?.Wait(); } catch (AggregateException) { }
_keepAliveCts.Dispose();
_keepAliveCts = null;
_keepAliveTask = null;
}
private async Task KeepAliveLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// Non-blocking try-acquire: if an operation holds the lock
// we skip this cycle — the operation itself keeps the bus alive.
if (await _busLock.WaitAsync(0, ct))
{
try
{
_sessionKwp!.KeepAlive();
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
_log.Error(LogId, $"Keep-alive failed: {ex.Message}");
CleanupSession();
SetState(KLineConnectionState.Failed);
return;
}
finally
{
_busLock.Release();
}
}
try
{
await Task.Delay(KeepAliveIntervalMs, ct);
}
catch (OperationCanceledException) { return; }
}
}
// ── Session state helpers ─────────────────────────────────────────────────
private void SetState(KLineConnectionState newState)
{
if (_kLineState == newState) return;
_kLineState = newState;
KLineStateChanged?.Invoke(newState);
}
private void CleanupSession()
{
_sessionIface?.Dispose();
_sessionIface = null;
_sessionKwpCommon = null;
_sessionKwp = null;
_connectedPort = null;
}
// ── Session-aware operation helpers ────────────────────────────────────────
private string ReadFaultCodesWithSession(CancellationToken ct)
{
_busLock.Wait(ct);
try
{
Report(50, "Reading fault codes...");
_sessionKwp!.KeepAlive();
var codes = _sessionKwp.ReadFaultCodes();
_sessionKwp.KeepAlive();
Report(100, "Done.");
return codes?.Count > 0
? string.Join(Environment.NewLine, codes)
: KlineKeys.NoErrors;
}
catch (Exception ex)
{
_log.Error(LogId, $"ReadFaultCodes (session): {ex.Message}");
CleanupSession();
SetState(KLineConnectionState.Failed);
return $"Error: {ex.Message}";
}
finally { _busLock.Release(); }
}
private string ClearFaultCodesWithSession(CancellationToken ct)
{
_busLock.Wait(ct);
try
{
Report(40, "Clearing fault codes...");
_sessionKwp!.KeepAlive();
_sessionKwp.ClearFaultCodes();
_sessionKwp.KeepAlive();
Report(70, "Reading fault codes...");
var codes = _sessionKwp.ReadFaultCodes();
_sessionKwp.KeepAlive();
Report(100, "Done.");
return codes?.Count > 0
? string.Join(Environment.NewLine, codes)
: KlineKeys.NoErrors;
}
catch (Exception ex)
{
_log.Error(LogId, $"ClearFaultCodes (session): {ex.Message}");
CleanupSession();
SetState(KLineConnectionState.Failed);
return $"Error: {ex.Message}";
}
finally { _busLock.Release(); }
}
private string ReadDfiWithSession(CancellationToken ct)
{
_busLock.Wait(ct);
try
{
Report(30, "Reading DFI calibration...");
_sessionKwp!.KeepAlive();
_sessionKwp.SendCustom(new List { 0x18, 0x00, 0x03, 0xFF, 0xFF });
_sessionKwp.KeepAlive();
var packets = _sessionKwp.SendCustom(new List { 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; }
_sessionKwp.KeepAlive();
Report(100, "Done.");
return dfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_log.Error(LogId, $"ReadDfi (session): {ex.Message}");
CleanupSession();
SetState(KLineConnectionState.Failed);
return "0";
}
finally { _busLock.Release(); }
}
private string WriteDfiWithSession(float dfi, int version, CancellationToken ct)
{
_busLock.Wait(ct);
double newDfi = 0;
try
{
var passPacket = version switch
{
1 => new List { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x30, 0x35, 0x30, 0x30, 0x30, 0x31, 0x1C, 0x09, 0x04 },
2 or 3 => new List { 0x18, 0x00, 0x03, 0xFF, 0xF2, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 },
_ => new List { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 }
};
Report(30, "Authenticating and writing DFI...");
_sessionKwp!.KeepAlive();
_sessionKwp.SendCustom(passPacket);
_sessionKwp.KeepAlive();
sbyte rawValue = (sbyte)((dfi * 256.0f) / 3.0f);
if (rawValue == 0) rawValue = 1;
byte checksum = (byte)(0 - (byte)rawValue);
_sessionKwp.SendCustom(new List { 0x1A, 0x02, 0x00, 0x44, (byte)rawValue, checksum, 0x03 });
_sessionKwp.KeepAlive();
Report(60, "Verifying write...");
_sessionKwp.SendCustom(new List { 0x18, 0x00, 0x03, 0xFF, 0xFF });
_sessionKwp.KeepAlive();
var packets = _sessionKwp.SendCustom(new List { 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; }
_sessionKwp.KeepAlive();
Report(100, "Done.");
}
catch (Exception ex)
{
_log.Error(LogId, $"WriteDfi (session): {ex.Message}");
CleanupSession();
SetState(KLineConnectionState.Failed);
}
finally { _busLock.Release(); }
return newDfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
// ── 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 { 0x18, 0x00, 0x03, 0x2F, 0xF2, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 },
1 => new List { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x30, 0x35, 0x30, 0x30, 0x30, 0x31, 0x1C, 0x09, 0x04 },
2 or 3 => new List { 0x18, 0x00, 0x03, 0xFF, 0xF2, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 },
_ => new List { 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 { 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 { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
var packets = kwp.SendCustom(new List { 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);
}
///
/// Sends the ROM address lookup command {0x01, 0x02, 0x00, 0xC6} once and
/// returns the raw 16-bit base address. Both the pump identifier (base − 10)
/// and the V2 customer-change index (base − 0x1D) derive from this value.
///
private ushort ReadBaseRomAddress(KW1281Connection kwp)
{
var packets = kwp.SendCustom(new List { 0x01, 0x02, 0x00, 0xC6 });
foreach (var pkt in packets)
if (pkt.Body.Count > 1)
return (ushort)((pkt.Body[1] << 8) | pkt.Body[0]);
return 0;
}
///
/// Reads the customer-change ROM address for non-V2 pumps using
/// the legacy ROM pointer at 0x9FFE.
///
private ushort ReadCustomerChangeAddressNonV2(KW1281Connection kwp)
{
var data = kwp.ReadRomEeprom(0x9FFE, 2);
if (data == null || data.Count < 2) return 0;
return (ushort)(((data[1] << 8) | data[0]) + 3);
}
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 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);
}
}