From 4891eb681231128b8f04ffe07f3873fd15f3df4c Mon Sep 17 00:00:00 2001 From: LucianoDev Date: Tue, 14 Apr 2026 21:25:30 +0200 Subject: [PATCH] feat: redesign bench calibration (factor/offset), add Ttank/P2 displays, fix sensor calibration - Replace P1-P6 rational transfer function with factor/offset model for bench params - Add explicit rx/tx direction flags in bench XML configuration - Add T.Tank (BenchTemp) and P2 (AnalogSensor2) to temperature/pressure display - Apply SensorConfiguration calibration to pressure channels, fix empty sensors.xml fallback - Add live value labels to flowmeter charts - Hide pump live values and PSG encoder standalone label - Add K-Line connection state model, improve KWP service and status displays - Restructure .claude/skills into subdirectory format Co-Authored-By: Claude Opus 4.6 --- .claude/skills/{build.md => build/SKILL.md} | 0 .../{kwp-review.md => kwp-review/SKILL.md} | 0 .../SKILL.md} | 0 .claude/skills/{ship.md => ship/SKILL.md} | 0 Infrastructure/Kwp/KW1281Connection.cs | 4 +- Infrastructure/Pcan/PcanAdapter.cs | 28 +- MainWindow.xaml | 36 +- Models/CanBusParameter.cs | 111 +++- Models/KLineConnectionState.cs | 17 + Services/IKwpService.cs | 42 ++ Services/Impl/ConfigurationService.cs | 120 ++-- Services/Impl/KwpService.cs | 608 ++++++++++++++++-- ViewModels/DfiManageViewModel.cs | 4 + ViewModels/MainViewModel.cs | 26 +- ViewModels/PumpIdentificationViewModel.cs | 47 +- ViewModels/SingleFlowChartViewModel.cs | 4 + ViewModels/StatusDisplayViewModel.cs | 5 +- Views/UserControls/FlowmeterChartView.xaml | 9 +- .../UserControls/PumpIdentificationView.xaml | 2 + Views/UserControls/StatusDisplayView.xaml | 3 + 20 files changed, 881 insertions(+), 185 deletions(-) rename .claude/skills/{build.md => build/SKILL.md} (100%) rename .claude/skills/{kwp-review.md => kwp-review/SKILL.md} (100%) rename .claude/skills/{protocol-ref.md => protocol-ref/SKILL.md} (100%) rename .claude/skills/{ship.md => ship/SKILL.md} (100%) create mode 100644 Models/KLineConnectionState.cs 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 @@ - + + + +