diff --git a/.claude/skills/build.md b/.claude/skills/build/SKILL.md
similarity index 100%
rename from .claude/skills/build.md
rename to .claude/skills/build/SKILL.md
diff --git a/.claude/skills/kwp-review.md b/.claude/skills/kwp-review/SKILL.md
similarity index 100%
rename from .claude/skills/kwp-review.md
rename to .claude/skills/kwp-review/SKILL.md
diff --git a/.claude/skills/protocol-ref.md b/.claude/skills/protocol-ref/SKILL.md
similarity index 100%
rename from .claude/skills/protocol-ref.md
rename to .claude/skills/protocol-ref/SKILL.md
diff --git a/.claude/skills/ship.md b/.claude/skills/ship/SKILL.md
similarity index 100%
rename from .claude/skills/ship.md
rename to .claude/skills/ship/SKILL.md
diff --git a/Infrastructure/Kwp/KW1281Connection.cs b/Infrastructure/Kwp/KW1281Connection.cs
index c410b56..6af6c6f 100644
--- a/Infrastructure/Kwp/KW1281Connection.cs
+++ b/Infrastructure/Kwp/KW1281Connection.cs
@@ -299,7 +299,9 @@ namespace HC_APTBS.Infrastructure.Kwp
{
var packet = ReceivePacket();
packets.Add(packet); // TODO: Maybe don't add the packet if it's an Ack
- if (packet is AckPacket || packet is NakPacket)
+ //SendAckPacket();
+
+ if (packet is AckPacket || packet is NakPacket)
{
break;
}
diff --git a/Infrastructure/Pcan/PcanAdapter.cs b/Infrastructure/Pcan/PcanAdapter.cs
index 9370174..7ea18d5 100644
--- a/Infrastructure/Pcan/PcanAdapter.cs
+++ b/Infrastructure/Pcan/PcanAdapter.cs
@@ -398,9 +398,10 @@ namespace HC_APTBS.Infrastructure.Pcan
double previousValue = param.Value;
- if (param.Name == BenchParameterNames.Temp || param.Name == PumpParameterNames.Temp)
+ if (param.Name == PumpParameterNames.Temp)
{
- // Temperature uses a special packed BCD / signed format depending on sensor type.
+ // Only pump "Temp" uses BCD / signed format. Bench temperatures
+ // ("BenchTemp", "T-in", etc.) go through the generic path below.
param.Value = DecodeTempValue(data, param);
}
else if (param.Name == PumpParameterNames.Rpm)
@@ -413,23 +414,34 @@ namespace HC_APTBS.Infrastructure.Pcan
{
// Generic 1-byte, 2-byte, or 3-byte big-endian integer.
int byteSpan = Math.Abs(param.ByteH - param.ByteL);
+ double rawValue;
if (byteSpan == 0)
{
- param.Value = data[param.ByteL];
+ rawValue = data[param.ByteL];
}
else if (byteSpan == 1)
{
- param.Value = (data[param.ByteH] << 8) | data[param.ByteL];
+ rawValue = (data[param.ByteH] << 8) | data[param.ByteL];
}
else
{
// 3-byte little-endian variant used for encoder/pulse counters.
- param.Value = (data[param.ByteL + 2] << 16)
- | (data[param.ByteL + 1] << 8)
- | data[param.ByteL];
+ rawValue = (data[param.ByteL + 2] << 16)
+ | (data[param.ByteL + 1] << 8)
+ | data[param.ByteL];
}
- param.Value = param.GetTransformResult();
+ if (param.UseLegacyTransform)
+ {
+ // Pump params: store raw then apply P1-P6 rational transfer function.
+ param.Value = rawValue;
+ param.Value = param.GetTransformResult();
+ }
+ else
+ {
+ // Bench params: apply factor/offset calibration directly.
+ param.Value = param.Calibrate(rawValue);
+ }
if (double.IsInfinity(param.Value)) param.Value = 0;
}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index a76168d..32cf193 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:HC_APTBS.ViewModels"
xmlns:uc="clr-namespace:HC_APTBS.Views.UserControls"
+ xmlns:models="clr-namespace:HC_APTBS.Models"
mc:Ignorable="d"
Title="HC_APTBS — Herlic Test Bench"
Height="1080" Width="1920"
@@ -174,9 +175,12 @@
@@ -245,22 +249,30 @@
+
+
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
-
+
+
+
@@ -286,7 +298,7 @@
-
+
@@ -310,7 +322,7 @@
-
+
diff --git a/Models/CanBusParameter.cs b/Models/CanBusParameter.cs
index 82096b0..903e7e4 100644
--- a/Models/CanBusParameter.cs
+++ b/Models/CanBusParameter.cs
@@ -8,13 +8,12 @@ namespace HC_APTBS.Models
/// Represents a single logical parameter exchanged over CAN.
/// Each parameter occupies up to two bytes within a CAN frame identified by .
///
- /// Transfer function (transmit / non-receive):
- /// output = ((P1 * Value + P2) / (P3 * Value + P4)) + P5 + P6
+ /// Bench params use the simple calibration model:
+ /// Linear: eng = raw * Factor + Offset
+ /// Inverse: eng = Factor / raw + Offset
///
- /// Inverse transfer function (receive):
- /// output = (−P2 − P3·P5 − P4·P6 + P4·Value) / (P1 + P3·P5 + P3·P6 − P3·Value)
- ///
- /// Set to use the identity transform (P1=1, P2=0, P3=0, P4=1, P5=0, P6=0).
+ /// Pump params (legacy) use the P1–P6 rational transfer function via
+ /// . Set to enable.
///
public class CanBusParameter
{
@@ -40,7 +39,18 @@ namespace HC_APTBS.Models
///
public int Type { get; set; } = 0;
- // ── Calibration coefficients (P1–P6) ─────────────────────────────────────
+ // ── Simple calibration (bench params) ────────────────────────────────────
+
+ /// Multiplication factor: eng = raw * Factor + Offset.
+ public double Factor { get; set; } = 1.0;
+
+ /// Additive offset: eng = raw * Factor + Offset.
+ public double Offset { get; set; }
+
+ /// When true, calibration uses eng = Factor / raw + Offset.
+ public bool IsInverse { get; set; }
+
+ // ── Legacy P1–P6 calibration (pump params) ───────────────────────────────
/// Transfer function coefficient P1 (numerator multiplier).
public double P1 { get; set; }
@@ -66,6 +76,12 @@ namespace HC_APTBS.Models
///
public bool DisableCalibration { get; set; }
+ ///
+ /// When true, is used instead of
+ /// . Enabled for pump params loaded via .
+ ///
+ public bool UseLegacyTransform { get; set; }
+
// ── Runtime state ─────────────────────────────────────────────────────────
/// Current decoded engineering-unit value (updated by the CAN read thread).
@@ -78,8 +94,8 @@ namespace HC_APTBS.Models
public bool NeedsUpdate { get; set; }
///
- /// When true this parameter is used in the receive direction and applies the
- /// inverse transfer function in .
+ /// True for receive-direction params (decoded from incoming CAN frames).
+ /// False for transmit-direction params (packed into outgoing frames).
///
public bool IsReceive { get; set; }
@@ -98,33 +114,62 @@ namespace HC_APTBS.Models
set => MessageId = value;
}
- // ── Transfer function ─────────────────────────────────────────────────────
+ // ── Simple calibration ────────────────────────────────────────────────────
///
- /// Applies the calibration transfer function to .
+ /// Converts a raw CAN value to engineering units using Factor/Offset.
+ ///
+ public double Calibrate(double raw)
+ {
+ if (IsInverse)
+ return raw != 0 ? Factor / raw + Offset : 0;
+ return raw * Factor + Offset;
+ }
+
+ ///
+ /// Converts an engineering value back to raw CAN value (for transmit).
+ ///
+ public double CalibrateReverse(double eng)
+ {
+ if (IsInverse)
+ {
+ double denom = eng - Offset;
+ return denom != 0 ? Factor / denom : 0;
+ }
+ return Factor != 0 ? (eng - Offset) / Factor : 0;
+ }
+
+ // ── Legacy P1–P6 transfer function (pump params) ─────────────────────────
+
+ ///
+ /// Applies the P1–P6 rational transfer function to .
+ /// Used only by pump params ( = true).
///
- /// Calibrated engineering-unit result.
public double GetTransformResult()
{
if (IsReceive)
{
- // Inverse function: maps a measured value back to the raw command value.
return (-P2 - P3 * P5 - P4 * P6 + P4 * Value)
/ (P1 + P3 * P5 + P3 * P6 - P3 * Value);
}
- // Forward function: maps a setpoint to the raw CAN integer to transmit.
return ((P1 * Value + P2) / (P3 * Value + P4)) + P5 + P6;
}
///
- /// Returns the rounded integer value ready to be packed into a CAN frame byte pair.
+ /// Returns the raw CAN value for transmission.
+ /// Delegates to simple or legacy calibration depending on param type.
///
- public double GetTransmitValue() => GetTransformResult();
+ public double GetTransmitValue()
+ {
+ if (UseLegacyTransform)
+ return GetTransformResult();
+ return CalibrateReverse(Value);
+ }
///
/// Resets calibration coefficients to the identity transform so the raw value
- /// passes through unchanged.
+ /// passes through unchanged.
///
public void SetIdentityCalibration()
{
@@ -152,7 +197,8 @@ namespace HC_APTBS.Models
StringComparison.OrdinalIgnoreCase),
Alpha = ParseDecimal(xe.Attribute("filter")?.Value, 1.0),
DisableCalibration = string.Equals(xe.Attribute("disableparams")?.Value,
- "true", StringComparison.OrdinalIgnoreCase)
+ "true", StringComparison.OrdinalIgnoreCase),
+ UseLegacyTransform = true
};
if (p.DisableCalibration)
@@ -173,7 +219,7 @@ namespace HC_APTBS.Models
}
/// Parses a decimal string that may use comma or dot as separator.
- private static double ParseDecimal(string? value, double fallback)
+ internal static double ParseDecimal(string? value, double fallback)
{
if (string.IsNullOrEmpty(value)) return fallback;
return double.Parse(value.Replace(',', '.'), CultureInfo.InvariantCulture);
@@ -183,22 +229,19 @@ namespace HC_APTBS.Models
public XElement ToXml()
{
var elm = new XElement(Name,
- new XAttribute("id", MessageId.ToString("X")),
- new XAttribute("byteh", ByteH),
- new XAttribute("bytel", ByteL),
- new XAttribute("filter", Alpha),
- new XAttribute("disableparams", DisableCalibration));
+ new XAttribute("id", MessageId.ToString("X")),
+ new XAttribute("byteh", ByteH),
+ new XAttribute("bytel", ByteL),
+ new XAttribute("direction", IsReceive ? "rx" : "tx"));
- if (!DisableCalibration)
- {
- elm.Add(
- new XAttribute("p1", P1),
- new XAttribute("p2", P2),
- new XAttribute("p3", P3),
- new XAttribute("p4", P4),
- new XAttribute("p5", P5),
- new XAttribute("p6", P6));
- }
+ if (Alpha != 1.0)
+ elm.Add(new XAttribute("filter", Alpha));
+ if (Factor != 1.0)
+ elm.Add(new XAttribute("factor", Factor));
+ if (Offset != 0.0)
+ elm.Add(new XAttribute("offset", Offset));
+ if (IsInverse)
+ elm.Add(new XAttribute("type", "inverse"));
return elm;
}
diff --git a/Models/KLineConnectionState.cs b/Models/KLineConnectionState.cs
new file mode 100644
index 0000000..ce490ee
--- /dev/null
+++ b/Models/KLineConnectionState.cs
@@ -0,0 +1,17 @@
+namespace HC_APTBS.Models
+{
+ ///
+ /// Represents the current state of the K-Line diagnostic session.
+ ///
+ public enum KLineConnectionState
+ {
+ /// No session established (indicator: gray).
+ Disconnected,
+
+ /// Session active and keep-alive succeeding (indicator: green).
+ Connected,
+
+ /// Session lost — keep-alive or operation failed (indicator: red).
+ Failed
+ }
+}
diff --git a/Services/IKwpService.cs b/Services/IKwpService.cs
index e7462e5..3d496f3 100644
--- a/Services/IKwpService.cs
+++ b/Services/IKwpService.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
+using HC_APTBS.Models;
namespace HC_APTBS.Services
{
@@ -11,6 +12,31 @@ namespace HC_APTBS.Services
///
public interface IKwpService
{
+ // ── Session lifecycle ─────────────────────────────────────────────────────
+
+ /// Current state of the persistent K-Line session.
+ KLineConnectionState KLineState { get; }
+
+ ///
+ /// Raised whenever the K-Line session state transitions.
+ /// Fires on a background thread; consumers must marshal to the UI thread.
+ ///
+ event Action? KLineStateChanged;
+
+ ///
+ /// Opens a persistent K-Line session: performs 5-baud slow-init,
+ /// reads ECU info, then starts a background keep-alive loop (~1 s interval).
+ ///
+ /// FTDI serial number or COM port identifier.
+ /// Cancellation token.
+ Task ConnectAsync(string port, CancellationToken ct = default);
+
+ ///
+ /// Stops the keep-alive loop, sends EndCommunication to the ECU,
+ /// and disposes the FTDI interface.
+ ///
+ void Disconnect();
+
// ── Progress reporting ────────────────────────────────────────────────────
///
@@ -84,6 +110,22 @@ namespace HC_APTBS.Services
///
string? DetectKLinePort();
+ // ── Mid-read notifications ────────────────────────────────────────────
+
+ ///
+ /// Raised during as soon as the pump identifier
+ /// string has been read from ROM, before the full read completes.
+ /// Fires on a background thread; consumers must marshal to the UI thread.
+ ///
+ event Action? PumpIdentified;
+
+ ///
+ /// Raised during when the DFI calibration
+ /// value has been read from EEPROM. Parameter is the DFI angle in degrees.
+ /// Fires on a background thread; consumers must marshal to the UI thread.
+ ///
+ event Action? DfiRead;
+
// ── Power cycle callbacks ─────────────────────────────────────────────────
///
diff --git a/Services/Impl/ConfigurationService.cs b/Services/Impl/ConfigurationService.cs
index 115a4ab..498e20f 100644
--- a/Services/Impl/ConfigurationService.cs
+++ b/Services/Impl/ConfigurationService.cs
@@ -409,24 +409,28 @@ namespace HC_APTBS.Services.Impl
private void LoadSensors()
{
_settings ??= new AppSettings();
- if (!File.Exists(SensorsXml))
+ if (File.Exists(SensorsXml))
{
- _settings.Sensors[1] = SensorConfiguration.DefaultPressureSensor();
- return;
- }
- try
- {
- var xdoc = XDocument.Load(SensorsXml);
- foreach (var xs in xdoc.Root!.Elements("sensor"))
+ try
{
- var sc = SensorConfiguration.FromXml(xs);
- _settings.Sensors[sc.Number] = sc;
+ 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}");
}
}
- catch (Exception ex)
- {
- _log.Error(LogId, $"LoadSensors failed: {ex.Message}");
- }
+
+ // Ensure default calibrations exist for the two analogue channels.
+ if (!_settings.Sensors.ContainsKey(1))
+ _settings.Sensors[1] = SensorConfiguration.DefaultPressureSensor();
+ if (!_settings.Sensors.ContainsKey(2))
+ _settings.Sensors[2] = new SensorConfiguration { Number = 2, SensorName = "AnalogSensor2" };
}
private void LoadClients()
@@ -463,37 +467,28 @@ namespace HC_APTBS.Services.Impl
// ── Parsing helpers ───────────────────────────────────────────────────────
+ ///
+ /// Parses a bench CAN parameter from an XML element.
+ /// Uses the clean factor/offset calibration model with explicit direction flags.
+ ///
private static CanBusParameter ParseParamElement(XElement xe)
{
- var p = new CanBusParameter
+ string direction = xe.Attribute("direction")?.Value ?? "rx";
+
+ return 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)
+ Alpha = CanBusParameter.ParseDecimal(xe.Attribute("filter")?.Value, 1.0),
+ IsReceive = string.Equals(direction, "rx", StringComparison.OrdinalIgnoreCase),
+ Factor = CanBusParameter.ParseDecimal(xe.Attribute("factor")?.Value, 1.0),
+ Offset = CanBusParameter.ParseDecimal(xe.Attribute("offset")?.Value, 0.0),
+ IsInverse = string.Equals(xe.Attribute("type")?.Value, "inverse",
+ StringComparison.OrdinalIgnoreCase),
+ UseLegacyTransform = false,
};
-
- 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)
@@ -576,30 +571,37 @@ namespace HC_APTBS.Services.Impl
// ── Default bench XML ─────────────────────────────────────────────────────
- /// Returns the factory-default bench parameter XML used when bench.xml is absent.
+ ///
+ /// Returns the factory-default bench parameter XML used when bench.xml is absent.
+ /// Uses direction/factor/offset calibration model. Defaults: direction="rx", factor=1, offset=0, type="linear".
+ ///
private static string DefaultBenchXml() => @"
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/Impl/KwpService.cs b/Services/Impl/KwpService.cs
index a76c0ed..e7db759 100644
--- a/Services/Impl/KwpService.cs
+++ b/Services/Impl/KwpService.cs
@@ -17,7 +17,7 @@ namespace HC_APTBS.Services.Impl
/// K-Line baud rate is 9600 bps.
///
///
- public sealed class KwpService : IKwpService
+ public sealed class KwpService : IKwpService, IDisposable
{
// ── Protocol constants ────────────────────────────────────────────────────
@@ -27,20 +27,52 @@ namespace HC_APTBS.Services.Impl
/// 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? PumpDisconnectRequested;
///
public event Action? PumpReconnectRequested;
+ ///
+ public event Action? KLineStateChanged;
+
+ // ── Session state ─────────────────────────────────────────────────────────
+
+ private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
+
+ ///
+ public KLineConnectionState KLineState => _kLineState;
+
// ── Constructor ───────────────────────────────────────────────────────────
/// Application logger.
@@ -49,19 +81,110 @@ namespace HC_APTBS.Services.Impl
_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)
{
- return await Task.Run(() => ReadAllInfo(port, pumpVersion, ct), ct);
+ // 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
{
@@ -91,21 +214,54 @@ namespace HC_APTBS.Services.Impl
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 { 0x18, 0x00, 0x01, 0x53, 0x72 });
- Report(40, "Reading DFI calibration value...");
+ // 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))
+ 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();
@@ -121,35 +277,23 @@ namespace HC_APTBS.Services.Impl
}
result[KlineKeys.Dfi] = dfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
- // 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;
- }
+ // Notify subscribers so the DFI slider updates in real time.
+ DfiRead?.Invoke(dfi);
- 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...");
+ 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.PumpId] = ident;
result[KlineKeys.SerialNumber] = serial;
- result[KlineKeys.ModelIndex] = custChangeIndex;
- Report(95, "Enabling signal and closing session...");
+ 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)
@@ -163,9 +307,17 @@ namespace HC_APTBS.Services.Impl
for (int i = 0; i < 10; i++) kwp.KeepAlive();
}
kwp.KeepAlive();
- kwp.EndCommunication();
+
+ // 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)
{
@@ -178,7 +330,130 @@ namespace HC_APTBS.Services.Impl
}
finally
{
- iface?.Dispose();
+ // 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))
+ 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;
@@ -189,6 +464,9 @@ namespace HC_APTBS.Services.Impl
///
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;
@@ -222,6 +500,9 @@ namespace HC_APTBS.Services.Impl
///
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;
@@ -259,6 +540,9 @@ namespace HC_APTBS.Services.Impl
///
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;
@@ -297,13 +581,27 @@ namespace HC_APTBS.Services.Impl
///
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)
{
- var result = await Task.Run(() => WriteDfiInternal(port, dfi, version, closeSession: true), ct);
+ 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();
@@ -333,6 +631,204 @@ namespace HC_APTBS.Services.Impl
}
}
+ // ── 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)
+ {
+ try
+ {
+ await Task.Delay(KeepAliveIntervalMs, ct);
+ }
+ catch (OperationCanceledException) { return; }
+
+ // 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))
+ continue;
+
+ 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();
+ }
+ }
+ }
+
+ // ── 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)
@@ -399,33 +895,31 @@ namespace HC_APTBS.Services.Impl
return newDfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
- private ushort ReadCustomerChangeAddress(KW1281Connection kwp, int pumpVersion)
- {
- if (pumpVersion == 2)
- {
- 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]) - 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)
+ ///
+ /// 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]) - 10);
+ 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);
diff --git a/ViewModels/DfiManageViewModel.cs b/ViewModels/DfiManageViewModel.cs
index de55c67..dc50219 100644
--- a/ViewModels/DfiManageViewModel.cs
+++ b/ViewModels/DfiManageViewModel.cs
@@ -31,6 +31,10 @@ namespace HC_APTBS.ViewModels
{
_kwp = kwpService;
_config = configService;
+
+ // Update the slider and LCD display in real time when the DFI is
+ // read during a full K-Line read (PumpIdentificationViewModel flow).
+ _kwp.DfiRead += (dfi) => SetDfi(dfi);
}
// ── DFI display ───────────────────────────────────────────────────────────
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index d2f40ae..97413a1 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -34,6 +34,7 @@ namespace HC_APTBS.ViewModels
// ── Services ──────────────────────────────────────────────────────────────
private readonly ICanService _can;
+ private readonly IKwpService _kwp;
private readonly IBenchService _bench;
private readonly IConfigurationService _config;
private readonly IPdfService _pdf;
@@ -96,6 +97,7 @@ namespace HC_APTBS.ViewModels
IAppLogger logger)
{
_can = canService;
+ _kwp = kwpService;
_bench = benchService;
_config = configService;
_pdf = pdfService;
@@ -129,6 +131,10 @@ namespace HC_APTBS.ViewModels
_can.PumpLivenessChanged += alive =>
App.Current.Dispatcher.Invoke(() => IsPumpConnected = alive);
+ // K-Line session state → indicator
+ _kwp.KLineStateChanged += state =>
+ App.Current.Dispatcher.Invoke(() => KLineState = state);
+
// Bench service events
_bench.TestStarted += OnTestStarted;
_bench.TestFinished += OnTestFinished;
@@ -276,15 +282,21 @@ namespace HC_APTBS.ViewModels
/// Auxiliary temperature T4 (°C).
[ObservableProperty] private double _temp4;
+ /// Oil tank temperature (°C).
+ [ObservableProperty] private double _benchTemp;
+
/// Fuel delivery measurement Q-delivery (cc/stroke).
[ObservableProperty] private double _qDelivery;
/// Fuel overflow/pilot measurement Q-over (cc/stroke).
[ObservableProperty] private double _qOver;
- /// Bench oil pressure (bar).
+ /// Bench oil pressure P1 (bar), sensor-calibrated.
[ObservableProperty] private double _pressure;
+ /// Analogue sensor 2 pressure P2 (bar), sensor-calibrated.
+ [ObservableProperty] private double _pressure2;
+
/// PSG encoder position value.
[ObservableProperty] private double _psgEncoderValue;
@@ -316,8 +328,8 @@ namespace HC_APTBS.ViewModels
/// True when oil circulation has been detected.
[ObservableProperty] private bool _isOilCirculating;
- /// True when a K-Line session is active.
- [ObservableProperty] private bool _isKLineConnected;
+ /// Current K-Line session state (Disconnected / Connected / Failed).
+ [ObservableProperty] private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
// ── Test status ───────────────────────────────────────────────────────────
@@ -500,11 +512,17 @@ namespace HC_APTBS.ViewModels
TempIn = _bench.ReadBenchParameter(BenchParameterNames.TempIn);
TempOut = _bench.ReadBenchParameter(BenchParameterNames.TempOut);
Temp4 = _bench.ReadBenchParameter(BenchParameterNames.Temp4);
+ BenchTemp = _bench.ReadBenchParameter(BenchParameterNames.Temp);
QDelivery = _bench.ReadBenchParameter(BenchParameterNames.QDelivery);
QOver = _bench.ReadBenchParameter(BenchParameterNames.QOver);
- Pressure = _bench.ReadBenchParameter(BenchParameterNames.Pressure);
PsgEncoderValue = _bench.ReadBenchParameter(BenchParameterNames.PsgEncoderValue);
+ // Apply analogue sensor calibration for pressure channels.
+ double rawP1 = _bench.ReadBenchParameter(BenchParameterNames.Pressure);
+ Pressure = _config.Settings.Sensors.TryGetValue(1, out var s1) ? s1.GetValueFromRaw(rawP1) : rawP1;
+ double rawP2 = _bench.ReadBenchParameter(BenchParameterNames.AnalogSensor2);
+ Pressure2 = _config.Settings.Sensors.TryGetValue(2, out var s2) ? s2.GetValueFromRaw(rawP2) : rawP2;
+
// Feed the angle display with all three encoder channels + status.
AngleDisplay.Update(
PsgEncoderValue,
diff --git a/ViewModels/PumpIdentificationViewModel.cs b/ViewModels/PumpIdentificationViewModel.cs
index 4fae7cb..6013d0c 100644
--- a/ViewModels/PumpIdentificationViewModel.cs
+++ b/ViewModels/PumpIdentificationViewModel.cs
@@ -47,6 +47,18 @@ namespace HC_APTBS.ViewModels
ProgressPercent = pct;
ProgressMessage = msg;
});
+
+ // Start loading the pump as soon as the identifier is read from ROM,
+ // before the full K-Line read completes.
+ _kwp.PumpIdentified += (pumpId) => App.Current.Dispatcher.Invoke(() =>
+ {
+ KlinePumpId = pumpId;
+ AutoSelectPumpByKlineId(pumpId);
+ });
+
+ // Track K-Line session state for Disconnect button enable/disable.
+ _kwp.KLineStateChanged += state =>
+ App.Current.Dispatcher.Invoke(() => KLineState = state);
}
// ── Pump selection ────────────────────────────────────────────────────────
@@ -106,8 +118,14 @@ namespace HC_APTBS.ViewModels
/// True while a K-Line read is in progress.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ReadKlineCommand))]
+ [NotifyCanExecuteChangedFor(nameof(DisconnectKLineCommand))]
private bool _isReading;
+ /// Current K-Line session state for Disconnect button enable/disable.
+ [ObservableProperty]
+ [NotifyCanExecuteChangedFor(nameof(DisconnectKLineCommand))]
+ private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
+
/// DFI calibration angle read from ECU EEPROM.
[ObservableProperty] private string _klineDfi = "-";
@@ -182,7 +200,10 @@ namespace HC_APTBS.ViewModels
{
KlineDfi = dfi ?? "-";
KlineErrors = errors ?? string.Empty;
- KlinePumpId = pumpId ?? string.Empty;
+ // Preserve the value set by the PumpIdentified event if the
+ // dictionary entry is empty (e.g. read failed after ident).
+ if (!string.IsNullOrEmpty(pumpId))
+ KlinePumpId = pumpId;
KlineSerialNumber = serial ?? string.Empty;
KlineModelRef = modelRef ?? string.Empty;
KlineModelIndex = modelIndex ?? string.Empty;
@@ -193,12 +214,8 @@ namespace HC_APTBS.ViewModels
KlineConnectError = connectErr ?? string.Empty;
});
- // Auto-select pump from K-Line pump ID — matches old source behaviour
- // where OnPumpConnectClick would call LoadPump(pumpId) after reading.
- if (result == "1" && !string.IsNullOrEmpty(pumpId))
- {
- AutoSelectPumpByKlineId(pumpId);
- }
+ // Pump auto-selection now happens via the PumpIdentified event
+ // mid-read, so there is no need to call AutoSelectPumpByKlineId here.
// Attach K-Line info to the (now possibly auto-selected) pump.
if (CurrentPump != null)
@@ -212,6 +229,22 @@ namespace HC_APTBS.ViewModels
private bool CanReadKline() => !IsReading;
+ /// Closes the persistent K-Line session.
+ [RelayCommand(CanExecute = nameof(CanDisconnectKLine))]
+ private void DisconnectKLine()
+ {
+ try
+ {
+ _kwp.Disconnect();
+ }
+ catch (Exception ex)
+ {
+ _log.Error(LogId, $"DisconnectKLine: {ex.Message}");
+ }
+ }
+
+ private bool CanDisconnectKLine() => !IsReading && KLineState == KLineConnectionState.Connected;
+
// ── Helpers ───────────────────────────────────────────────────────────────
///
diff --git a/ViewModels/SingleFlowChartViewModel.cs b/ViewModels/SingleFlowChartViewModel.cs
index 361bfed..1fdb4c5 100644
--- a/ViewModels/SingleFlowChartViewModel.cs
+++ b/ViewModels/SingleFlowChartViewModel.cs
@@ -23,6 +23,9 @@ namespace HC_APTBS.ViewModels
/// Chart title label.
[ObservableProperty] private string _title = string.Empty;
+ /// Most recent sample value, displayed as a numeric label alongside the chart.
+ [ObservableProperty] private double _currentValue;
+
/// Series array bound to the CartesianChart.
public ISeries[] Series { get; }
@@ -88,6 +91,7 @@ namespace HC_APTBS.ViewModels
public void AddValue(double value)
{
_values.Add(value);
+ CurrentValue = value;
if (_values.Count > _maxSamples)
_values.RemoveAt(0);
}
diff --git a/ViewModels/StatusDisplayViewModel.cs b/ViewModels/StatusDisplayViewModel.cs
index 69649a3..9eda47b 100644
--- a/ViewModels/StatusDisplayViewModel.cs
+++ b/ViewModels/StatusDisplayViewModel.cs
@@ -40,6 +40,9 @@ namespace HC_APTBS.ViewModels
get => _isActive;
set => SetProperty(ref _isActive, value);
}
+
+ /// Zero-based bit position (0–15) shown as a label beneath the indicator.
+ public int Index { get; init; }
}
///
@@ -73,7 +76,7 @@ namespace HC_APTBS.ViewModels
public StatusDisplayViewModel()
{
for (int i = 0; i < 16; i++)
- Bits.Add(new BitIndicatorViewModel { Color = "#26C200", Description = $"Bit {i}" });
+ Bits.Add(new BitIndicatorViewModel { Color = "#26C200", Description = $"Bit {i}", Index = i });
}
// ── Public API ────────────────────────────────────────────────────────────
diff --git a/Views/UserControls/FlowmeterChartView.xaml b/Views/UserControls/FlowmeterChartView.xaml
index e1a743e..1d96331 100644
--- a/Views/UserControls/FlowmeterChartView.xaml
+++ b/Views/UserControls/FlowmeterChartView.xaml
@@ -9,8 +9,13 @@
-
+
+
+
+
+