Files
HC_APTBS/Services/Impl/KwpService.cs
LucianoDev 827b811b39 feat: developer tools page, auto-test orchestrator, BIP display, tests redesign
Bundles several feature streams that have been iterating on the working tree:

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 13:59:50 +02:00

1170 lines
48 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <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, IDisposable
{
// ── 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;
/// <summary>Interval between keep-alive ACK packets (ms).</summary>
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 ────────────────────────────────────────────────────────────────
/// <inheritdoc/>
public event Action<int, string>? ProgressChanged;
/// <inheritdoc/>
public event Action<string>? PumpIdentified;
/// <inheritdoc/>
public event Action<double>? DfiRead;
/// <inheritdoc/>
public event Action<ushort>? BipStatusRead;
/// <inheritdoc/>
public event Action? PumpDisconnectRequested;
/// <inheritdoc/>
public event Action? PumpReconnectRequested;
/// <inheritdoc/>
public event Action<KLineConnectionState>? KLineStateChanged;
// ── Session state ─────────────────────────────────────────────────────────
private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
/// <inheritdoc/>
public KLineConnectionState KLineState => _kLineState;
/// <inheritdoc/>
public string? ConnectedPort => _connectedPort;
// ── Constructor ───────────────────────────────────────────────────────────
/// <param name="logger">Application logger.</param>
public KwpService(IAppLogger logger)
{
_log = logger;
}
// ── IKwpService: session lifecycle ────────────────────────────────────────
/// <inheritdoc/>
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();
}
/// <inheritdoc/>
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.");
}
/// <inheritdoc/>
public void Dispose()
{
StopKeepAlive();
CleanupSession();
_busLock.Dispose();
}
// ── IKwpService: full read ────────────────────────────────────────────────
/// <inheritdoc/>
public async Task<Dictionary<string, string>> 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<string, string> ReadAllInfo(string port, int pumpVersion, CancellationToken ct)
{
var result = new Dictionary<string, string> { [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<byte> { 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<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;
}
// 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<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);
// 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<byte> { 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<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();
// 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;
}
/// <summary>
/// Session-aware variant of <see cref="ReadAllInfo"/>. Reuses the persistent
/// K-Line session, skipping WakeUp and ReadEcuInfo. The session stays alive
/// afterward (no EndCommunication).
/// </summary>
private Dictionary<string, string> ReadAllInfoWithSession(int pumpVersion, CancellationToken ct)
{
var result = new Dictionary<string, string> { [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<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 });
// 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;
}
// 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<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
kwp.KeepAlive();
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);
DfiRead?.Invoke(dfi);
Report(75, "Reading serial number...");
kwp.KeepAlive();
string serial = ReadEepromString(kwp, new List<byte> { 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<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();
// 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 ───────────────────────────────────────────
/// <inheritdoc/>
public async Task<string> 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);
}
/// <inheritdoc/>
public async Task<string> 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 ───────────────────────────────────────────
/// <inheritdoc/>
public async Task<string> 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<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)
{
if (_kLineState == KLineConnectionState.Connected)
return await Task.Run(() => WriteDfiWithSession(dfi, version, ct), ct);
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)
{
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 ──────────────────────────────────
/// <inheritdoc/>
public async Task<bool> TryFastUnlockAsync(int unlockType)
{
byte ramByte = unlockType switch
{
1 => 0xA8,
2 => 0xE8,
_ => 0x00
};
if (ramByte == 0x00)
{
_log.Info(LogId, $"TryFastUnlock: unsupported unlockType={unlockType} — skipping");
return false;
}
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, $"TryFastUnlock(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<byte> { 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) ──────────────────────
/// <inheritdoc/>
public async Task<IReadOnlyList<byte[]>> SendRawCustomAsync(byte[] payload, CancellationToken ct = default)
{
if (payload is null || payload.Length == 0)
{
_log.Info(LogId, "SendRawCustom: empty payload — skipping");
return Array.Empty<byte[]>();
}
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "SendRawCustom: no active K-Line session — skipping");
return Array.Empty<byte[]>();
}
return await Task.Run<IReadOnlyList<byte[]>>(() =>
{
_busLock.Wait(ct);
try
{
var hex = string.Join(" ", payload.Select(b => b.ToString("X2")));
_log.Info(LogId, $"SendRawCustom: TX {hex}");
var packets = _sessionKwp!.SendCustom(payload.ToList());
var response = packets.Select(p => p.Bytes.ToArray()).ToArray();
_log.Info(LogId, $"SendRawCustom: RX {response.Length} packet(s)");
return response;
}
catch (Exception ex)
{
_log.Warning(LogId, $"SendRawCustom failed: {ex.Message}");
return Array.Empty<byte[]>();
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: typed read primitives (developer tools) ──────────────────
/// <inheritdoc/>
public async Task<IReadOnlyList<byte>> ReadEepromAsync(ushort address, byte count, CancellationToken ct = default)
{
if (count == 0) return Array.Empty<byte>();
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "ReadEeprom: no active K-Line session — skipping");
return Array.Empty<byte>();
}
return await Task.Run<IReadOnlyList<byte>>(() =>
{
_busLock.Wait(ct);
try
{
var bytes = _sessionKwp!.ReadEeprom(address, count);
return bytes != null ? (IReadOnlyList<byte>)bytes : Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(LogId, $"ReadEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
return Array.Empty<byte>();
}
finally
{
_busLock.Release();
}
}, ct);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<byte>> ReadRomEepromAsync(ushort address, byte count, CancellationToken ct = default)
{
if (count == 0) return Array.Empty<byte>();
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
{
_log.Info(LogId, "ReadRomEeprom: no active K-Line session — skipping");
return Array.Empty<byte>();
}
return await Task.Run<IReadOnlyList<byte>>(() =>
{
_busLock.Wait(ct);
try
{
var bytes = _sessionKwp!.ReadRomEeprom(address, count);
return bytes != null ? (IReadOnlyList<byte>)bytes : Array.Empty<byte>();
}
catch (Exception ex)
{
_log.Warning(LogId, $"ReadRomEeprom(0x{address:X4}, {count}) failed: {ex.Message}");
return Array.Empty<byte>();
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: BIP status ───────────────────────────────────────────────
/// <inheritdoc/>
public async Task<ushort?> ReadBipStatusAsync(CancellationToken ct = default)
{
if (_kLineState != KLineConnectionState.Connected || _sessionKwp == null)
return null;
return await Task.Run(() =>
{
_busLock.Wait(ct);
try
{
// ReadEeprom (0x19), 2 bytes, at RAM address 0x0106 (ADR-S_BIP_HW_UW).
// Byte order is little-endian, consistent with ReadCustomerChangeAddress.
var packets = _sessionKwp!.SendCustom(
new List<byte> { (byte)PacketCommand.ReadEeprom, 0x02, 0x01, 0x06 });
foreach (var pkt in packets)
{
if (pkt is ReadEepromResponsePacket && pkt.Body.Count >= 2)
{
ushort word = (ushort)((pkt.Body[1] << 8) | pkt.Body[0]);
_log.Debug(LogId, $"ReadBipStatus: 0x{word:X4}");
BipStatusRead?.Invoke(word);
return (ushort?)word;
}
}
_log.Warning(LogId, "ReadBipStatus: no ReadEepromResponse received");
return null;
}
catch (OperationCanceledException) { return null; }
catch (Exception ex)
{
_log.Warning(LogId, $"ReadBipStatus failed: {ex.Message}");
return null;
}
finally
{
_busLock.Release();
}
}, ct);
}
// ── IKwpService: device detection ────────────────────────────────────────
/// <inheritdoc/>
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<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
_sessionKwp.KeepAlive();
var packets = _sessionKwp.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; }
_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<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 }
};
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<byte> { 0x1A, 0x02, 0x00, 0x44, (byte)rawValue, checksum, 0x03 });
_sessionKwp.KeepAlive();
Report(60, "Verifying write...");
_sessionKwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
_sessionKwp.KeepAlive();
var packets = _sessionKwp.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; }
_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<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);
}
/// <summary>
/// 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.
/// </summary>
private ushort ReadBaseRomAddress(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]);
return 0;
}
/// <summary>
/// Reads the customer-change ROM address for non-V2 pumps using
/// the legacy ROM pointer at 0x9FFE.
/// </summary>
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<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);
}
}