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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<Border.Style>
|
||||
<Style TargetType="Border" BasedOn="{StaticResource ConnIndicator}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsKLineConnected}" Value="True">
|
||||
<DataTrigger Binding="{Binding KLineState}" Value="{x:Static models:KLineConnectionState.Connected}">
|
||||
<Setter Property="Background" Value="#26C200"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding KLineState}" Value="{x:Static models:KLineConnectionState.Failed}">
|
||||
<Setter Property="Background" Value="#FF3333"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
@@ -245,22 +249,30 @@
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="T. In:" Grid.Row="0" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="T. Out:" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="T. 4:" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="Pres.:" Grid.Row="3" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="T. In:" Grid.Row="0" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="T. Out:" Grid.Row="1" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="T. 4:" Grid.Row="2" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="T. Tank:" Grid.Row="3" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="P1:" Grid.Row="4" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
<TextBlock Text="P2:" Grid.Row="5" VerticalAlignment="Center" Foreground="#EBEBFF" FontSize="13"/>
|
||||
|
||||
<TextBlock Text="{Binding TempIn, StringFormat=F1}" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding TempOut, StringFormat=F1}" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding Temp4, StringFormat=F1}" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding Pressure, StringFormat=F1}" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding TempIn, StringFormat=F1}" Grid.Row="0" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding TempOut, StringFormat=F1}" Grid.Row="1" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding Temp4, StringFormat=F1}" Grid.Row="2" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding BenchTemp, StringFormat=F1}" Grid.Row="3" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding Pressure, StringFormat=F1}" Grid.Row="4" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
<TextBlock Text="{Binding Pressure2, StringFormat=F1}" Grid.Row="5" Grid.Column="1" HorizontalAlignment="Right" Foreground="#EBEBFF" FontSize="20" FontFamily="Consolas"/>
|
||||
|
||||
<TextBlock Text="°C" Grid.Row="0" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
<TextBlock Text="°C" Grid.Row="1" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
<TextBlock Text="°C" Grid.Row="2" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
<TextBlock Text="bar" Grid.Row="3" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
<TextBlock Text="°C" Grid.Row="3" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
<TextBlock Text="bar" Grid.Row="4" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
<TextBlock Text="bar" Grid.Row="5" Grid.Column="2" Foreground="#EBEBFF" FontSize="13" VerticalAlignment="Center" Margin="4,0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -286,7 +298,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- Pump live values -->
|
||||
<Border BorderBrush="#888" BorderThickness="1" Margin="0,4" Padding="8,4">
|
||||
<Border BorderBrush="#888" BorderThickness="1" Margin="0,4" Padding="8,4" Visibility="Collapsed">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="70"/>
|
||||
@@ -310,7 +322,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- PSG encoder value -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4">
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4" Visibility="Collapsed">
|
||||
<TextBlock Text="PSG Encoder:" VerticalAlignment="Center" FontSize="12" Margin="0,0,8,0"/>
|
||||
<TextBlock Text="{Binding PsgEncoderValue, StringFormat=F2}"
|
||||
FontSize="16" FontFamily="Consolas" VerticalAlignment="Center"/>
|
||||
|
||||
@@ -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 <see cref="MessageId"/>.
|
||||
///
|
||||
/// <para><b>Transfer function (transmit / non-receive):</b><br/>
|
||||
/// <c>output = ((P1 * Value + P2) / (P3 * Value + P4)) + P5 + P6</c></para>
|
||||
/// <para><b>Bench params</b> use the simple calibration model:<br/>
|
||||
/// Linear: <c>eng = raw * Factor + Offset</c><br/>
|
||||
/// Inverse: <c>eng = Factor / raw + Offset</c></para>
|
||||
///
|
||||
/// <para><b>Inverse transfer function (receive):</b><br/>
|
||||
/// <c>output = (−P2 − P3·P5 − P4·P6 + P4·Value) / (P1 + P3·P5 + P3·P6 − P3·Value)</c></para>
|
||||
///
|
||||
/// <para>Set <see cref="DisableCalibration"/> to use the identity transform (P1=1, P2=0, P3=0, P4=1, P5=0, P6=0).</para>
|
||||
/// <para><b>Pump params</b> (legacy) use the P1–P6 rational transfer function via
|
||||
/// <see cref="GetTransformResult"/>. Set <see cref="UseLegacyTransform"/> to enable.</para>
|
||||
/// </summary>
|
||||
public class CanBusParameter
|
||||
{
|
||||
@@ -40,7 +39,18 @@ namespace HC_APTBS.Models
|
||||
/// </summary>
|
||||
public int Type { get; set; } = 0;
|
||||
|
||||
// ── Calibration coefficients (P1–P6) ─────────────────────────────────────
|
||||
// ── Simple calibration (bench params) ────────────────────────────────────
|
||||
|
||||
/// <summary>Multiplication factor: <c>eng = raw * Factor + Offset</c>.</summary>
|
||||
public double Factor { get; set; } = 1.0;
|
||||
|
||||
/// <summary>Additive offset: <c>eng = raw * Factor + Offset</c>.</summary>
|
||||
public double Offset { get; set; }
|
||||
|
||||
/// <summary>When true, calibration uses <c>eng = Factor / raw + Offset</c>.</summary>
|
||||
public bool IsInverse { get; set; }
|
||||
|
||||
// ── Legacy P1–P6 calibration (pump params) ───────────────────────────────
|
||||
|
||||
/// <summary>Transfer function coefficient P1 (numerator multiplier).</summary>
|
||||
public double P1 { get; set; }
|
||||
@@ -66,6 +76,12 @@ namespace HC_APTBS.Models
|
||||
/// </summary>
|
||||
public bool DisableCalibration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, <see cref="GetTransformResult"/> is used instead of
|
||||
/// <see cref="Calibrate"/>. Enabled for pump params loaded via <see cref="FromXml"/>.
|
||||
/// </summary>
|
||||
public bool UseLegacyTransform { get; set; }
|
||||
|
||||
// ── Runtime state ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Current decoded engineering-unit value (updated by the CAN read thread).</summary>
|
||||
@@ -78,8 +94,8 @@ namespace HC_APTBS.Models
|
||||
public bool NeedsUpdate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true this parameter is used in the receive direction and applies the
|
||||
/// inverse transfer function in <see cref="GetTransformResult"/>.
|
||||
/// True for receive-direction params (decoded from incoming CAN frames).
|
||||
/// False for transmit-direction params (packed into outgoing frames).
|
||||
/// </summary>
|
||||
public bool IsReceive { get; set; }
|
||||
|
||||
@@ -98,33 +114,62 @@ namespace HC_APTBS.Models
|
||||
set => MessageId = value;
|
||||
}
|
||||
|
||||
// ── Transfer function ─────────────────────────────────────────────────────
|
||||
// ── Simple calibration ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Applies the calibration transfer function to <see cref="Value"/>.
|
||||
/// Converts a raw CAN value to engineering units using Factor/Offset.
|
||||
/// </summary>
|
||||
public double Calibrate(double raw)
|
||||
{
|
||||
if (IsInverse)
|
||||
return raw != 0 ? Factor / raw + Offset : 0;
|
||||
return raw * Factor + Offset;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an engineering value back to raw CAN value (for transmit).
|
||||
/// </summary>
|
||||
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) ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Applies the P1–P6 rational transfer function to <see cref="Value"/>.
|
||||
/// Used only by pump params (<see cref="UseLegacyTransform"/> = true).
|
||||
/// </summary>
|
||||
/// <returns>Calibrated engineering-unit result.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double GetTransmitValue() => GetTransformResult();
|
||||
public double GetTransmitValue()
|
||||
{
|
||||
if (UseLegacyTransform)
|
||||
return GetTransformResult();
|
||||
return CalibrateReverse(Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets calibration coefficients to the identity transform so the raw value
|
||||
/// passes through <see cref="GetTransformResult"/> unchanged.
|
||||
/// passes through unchanged.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Parses a decimal string that may use comma or dot as separator.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
17
Models/KLineConnectionState.cs
Normal file
17
Models/KLineConnectionState.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace HC_APTBS.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the current state of the K-Line diagnostic session.
|
||||
/// </summary>
|
||||
public enum KLineConnectionState
|
||||
{
|
||||
/// <summary>No session established (indicator: gray).</summary>
|
||||
Disconnected,
|
||||
|
||||
/// <summary>Session active and keep-alive succeeding (indicator: green).</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>Session lost — keep-alive or operation failed (indicator: red).</summary>
|
||||
Failed
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public interface IKwpService
|
||||
{
|
||||
// ── Session lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Current state of the persistent K-Line session.</summary>
|
||||
KLineConnectionState KLineState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever the K-Line session state transitions.
|
||||
/// Fires on a background thread; consumers must marshal to the UI thread.
|
||||
/// </summary>
|
||||
event Action<KLineConnectionState>? KLineStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a persistent K-Line session: performs 5-baud slow-init,
|
||||
/// reads ECU info, then starts a background keep-alive loop (~1 s interval).
|
||||
/// </summary>
|
||||
/// <param name="port">FTDI serial number or COM port identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task ConnectAsync(string port, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stops the keep-alive loop, sends EndCommunication to the ECU,
|
||||
/// and disposes the FTDI interface.
|
||||
/// </summary>
|
||||
void Disconnect();
|
||||
|
||||
// ── Progress reporting ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -84,6 +110,22 @@ namespace HC_APTBS.Services
|
||||
/// </summary>
|
||||
string? DetectKLinePort();
|
||||
|
||||
// ── Mid-read notifications ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Raised during <see cref="ReadAllInfoAsync"/> 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.
|
||||
/// </summary>
|
||||
event Action<string>? PumpIdentified;
|
||||
|
||||
/// <summary>
|
||||
/// Raised during <see cref="ReadAllInfoAsync"/> 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.
|
||||
/// </summary>
|
||||
event Action<double>? DfiRead;
|
||||
|
||||
// ── Power cycle callbacks ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses a bench CAN parameter from an XML element.
|
||||
/// Uses the clean factor/offset calibration model with explicit direction flags.
|
||||
/// </summary>
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Returns the factory-default bench parameter XML used when bench.xml is absent.</summary>
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
private static string DefaultBenchXml() => @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||
<Bench>
|
||||
<RPM id=""10"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||
<Counter id=""11"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||
<BaudRate id=""55"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||
<BenchRPM id=""13"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""true"" />
|
||||
<BenchCounter id=""13"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""true"" />
|
||||
<BenchTemp id=""14"" byteh=""1"" bytel=""0"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||
<T-in id=""14"" byteh=""3"" bytel=""2"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||
<T-out id=""14"" byteh=""5"" bytel=""4"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||
<T4 id=""14"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""10"" p5=""-20"" p6=""0"" />
|
||||
<QDelivery id=""8"" byteh=""5"" bytel=""3"" filter=""0.01"" disableparams=""false"" p1=""0"" p2=""2.03"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
|
||||
<QOver id=""8"" byteh=""2"" bytel=""0"" filter=""0.11"" disableparams=""false"" p1=""0"" p2=""0.51"" p3=""1E-06"" p4=""0"" p5=""0"" p6=""0"" />
|
||||
<PSGEncoderValue id=""50"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||
<PSGEncoderWorking id=""50"" byteh=""7"" bytel=""7"" filter=""1"" disableparams=""true"" />
|
||||
<InyectorEncoderValue id=""50"" byteh=""2"" bytel=""3"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||
<InyectorEncoderWorking id=""50"" byteh=""6"" bytel=""6"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||
<ManualEncoderValue id=""50"" byteh=""0"" bytel=""1"" filter=""1"" disableparams=""false"" p1=""1"" p2=""0"" p3=""0"" p4=""1"" p5=""0"" p6=""0"" />
|
||||
<EncoderResolution id=""51"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||
<ElectronicMsg id=""51"" byteh=""0"" bytel=""0"" filter=""1"" disableparams=""true"" send=""true"" />
|
||||
<Alarms id=""8"" byteh=""7"" bytel=""6"" filter=""1"" disableparams=""true"" />
|
||||
<Pressure id=""13"" byteh=""4"" bytel=""5"" filter=""1"" disableparams=""true"" />
|
||||
<AnalogicSensor2 id=""13"" byteh=""6"" bytel=""7"" filter=""1"" disableparams=""true"" />
|
||||
<!-- TX: values sent from software to bench controller -->
|
||||
<RPM id=""10"" byteh=""1"" bytel=""0"" direction=""tx"" />
|
||||
<Counter id=""11"" byteh=""1"" bytel=""0"" direction=""tx"" />
|
||||
<BaudRate id=""55"" byteh=""0"" bytel=""0"" direction=""tx"" />
|
||||
<EncoderResolution id=""51"" byteh=""6"" bytel=""7"" direction=""tx"" />
|
||||
<ElectronicMsg id=""51"" byteh=""0"" bytel=""0"" direction=""tx"" />
|
||||
|
||||
<!-- RX: values received from bench controller (direction=""rx"" is the default) -->
|
||||
<BenchRPM id=""13"" byteh=""1"" bytel=""0"" />
|
||||
<BenchCounter id=""13"" byteh=""3"" bytel=""2"" />
|
||||
<BenchTemp id=""14"" byteh=""1"" bytel=""0"" factor=""0.1"" offset=""-20"" />
|
||||
<T-in id=""14"" byteh=""3"" bytel=""2"" factor=""0.1"" offset=""-20"" />
|
||||
<T-out id=""14"" byteh=""5"" bytel=""4"" factor=""0.1"" offset=""-20"" />
|
||||
<T4 id=""14"" byteh=""7"" bytel=""6"" factor=""0.1"" offset=""-20"" />
|
||||
<QDelivery id=""8"" byteh=""5"" bytel=""3"" factor=""2030000"" type=""inverse"" filter=""0.01"" />
|
||||
<QOver id=""8"" byteh=""2"" bytel=""0"" factor=""510000"" type=""inverse"" filter=""0.11"" />
|
||||
<PSGEncoderValue id=""50"" byteh=""4"" bytel=""5"" />
|
||||
<PSGEncoderWorking id=""50"" byteh=""7"" bytel=""7"" />
|
||||
<InyectorEncoderValue id=""50"" byteh=""2"" bytel=""3"" />
|
||||
<InyectorEncoderWorking id=""50"" byteh=""6"" bytel=""6"" />
|
||||
<ManualEncoderValue id=""50"" byteh=""0"" bytel=""1"" />
|
||||
<Alarms id=""8"" byteh=""7"" bytel=""6"" />
|
||||
<Pressure id=""13"" byteh=""4"" bytel=""5"" />
|
||||
<AnalogicSensor2 id=""13"" byteh=""6"" bytel=""7"" />
|
||||
|
||||
<Reles>
|
||||
<Rele name=""Electronic"" id=""15"" bit=""0"" />
|
||||
<Rele name=""OilPump"" id=""15"" bit=""4"" />
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace HC_APTBS.Services.Impl
|
||||
/// K-Line baud rate is 9600 bps.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class KwpService : IKwpService
|
||||
public sealed class KwpService : IKwpService, IDisposable
|
||||
{
|
||||
// ── Protocol constants ────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,20 +27,52 @@ namespace HC_APTBS.Services.Impl
|
||||
/// <summary>K-Line baud rate (bps) for all VP44 communications.</summary>
|
||||
private const int KLineBaudRate = 9600;
|
||||
|
||||
/// <summary>Interval between keep-alive ACK packets (ms).</summary>
|
||||
private const int KeepAliveIntervalMs = 1000;
|
||||
|
||||
private readonly IAppLogger _log;
|
||||
private const string LogId = "KwpService";
|
||||
|
||||
// ── Persistent session fields ─────────────────────────────────────────────
|
||||
|
||||
private FtdiInterface? _sessionIface;
|
||||
private KwpCommon? _sessionKwpCommon;
|
||||
private KW1281Connection? _sessionKwp;
|
||||
private string? _connectedPort;
|
||||
|
||||
// ── Synchronization ───────────────────────────────────────────────────────
|
||||
|
||||
private readonly SemaphoreSlim _busLock = new(1, 1);
|
||||
private CancellationTokenSource? _keepAliveCts;
|
||||
private Task? _keepAliveTask;
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<int, string>? ProgressChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<string>? PumpIdentified;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<double>? DfiRead;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action? PumpDisconnectRequested;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action? PumpReconnectRequested;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<KLineConnectionState>? KLineStateChanged;
|
||||
|
||||
// ── Session state ─────────────────────────────────────────────────────────
|
||||
|
||||
private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public KLineConnectionState KLineState => _kLineState;
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <param name="logger">Application logger.</param>
|
||||
@@ -49,19 +81,110 @@ namespace HC_APTBS.Services.Impl
|
||||
_log = logger;
|
||||
}
|
||||
|
||||
// ── IKwpService: session lifecycle ────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ConnectAsync(string port, CancellationToken ct = default)
|
||||
{
|
||||
if (_kLineState == KLineConnectionState.Connected)
|
||||
throw new InvalidOperationException("K-Line session is already active. Disconnect first.");
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Report(10, "Connecting to K-Line interface...");
|
||||
var iface = new FtdiInterface(port, KLineBaudRate);
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var kwpCommon = new KwpCommon(iface);
|
||||
kwpCommon.WakeUp(EcuInitAddress);
|
||||
var kwp = new KW1281Connection(kwpCommon);
|
||||
|
||||
Report(50, "Reading ECU identification...");
|
||||
kwp.ReadEcuInfo();
|
||||
|
||||
// Store session objects.
|
||||
_sessionIface = iface;
|
||||
_sessionKwpCommon = kwpCommon;
|
||||
_sessionKwp = kwp;
|
||||
_connectedPort = port;
|
||||
|
||||
Report(100, "K-Line session established.");
|
||||
_log.Info(LogId, $"Persistent session opened on {port}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
iface.Dispose();
|
||||
throw;
|
||||
}
|
||||
}, ct);
|
||||
|
||||
SetState(KLineConnectionState.Connected);
|
||||
StartKeepAlive();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Disconnect()
|
||||
{
|
||||
StopKeepAlive();
|
||||
|
||||
_busLock.Wait();
|
||||
try
|
||||
{
|
||||
if (_sessionKwp != null)
|
||||
{
|
||||
try { _sessionKwp.EndCommunication(); }
|
||||
catch (Exception ex) { _log.Warning(LogId, $"EndCommunication on disconnect: {ex.Message}"); }
|
||||
}
|
||||
CleanupSession();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busLock.Release();
|
||||
}
|
||||
|
||||
SetState(KLineConnectionState.Disconnected);
|
||||
_log.Info(LogId, "Persistent session disconnected.");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
StopKeepAlive();
|
||||
CleanupSession();
|
||||
_busLock.Dispose();
|
||||
}
|
||||
|
||||
// ── IKwpService: full read ────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Dictionary<string, string>> ReadAllInfoAsync(
|
||||
string port, int pumpVersion, CancellationToken ct = default)
|
||||
{
|
||||
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<string, string> ReadAllInfo(string port, int pumpVersion, CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<string, string> { [KlineKeys.Result] = "0" };
|
||||
FtdiInterface? iface = null;
|
||||
bool promoteSession = false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -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<byte> { 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<byte> { 0x18, 0x00, 0x00, 0x82, 0x33 }); break;
|
||||
case 1: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x72, 0x53 }); break;
|
||||
case 2: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 }); break;
|
||||
}
|
||||
|
||||
// Read the ROM base address once (0xC6 command). Both the pump
|
||||
// identifier and the V2 customer-change index derive from it.
|
||||
Report(40, "Reading pump identifier...");
|
||||
kwp.KeepAlive();
|
||||
ushort baseAddr = ReadBaseRomAddress(kwp);
|
||||
ushort identAddr = (ushort)(baseAddr >= 10 ? baseAddr - 10 : 0);
|
||||
string ident = identAddr != 0 ? ReadRomString(kwp, identAddr, 10) : string.Empty;
|
||||
result[KlineKeys.PumpId] = ident;
|
||||
|
||||
// Notify subscribers immediately so the pump definition and its
|
||||
// tests can start loading while the K-Line read continues.
|
||||
if (!string.IsNullOrEmpty(ident))
|
||||
PumpIdentified?.Invoke(ident);
|
||||
|
||||
Report(55, "Reading customer change index...");
|
||||
kwp.KeepAlive();
|
||||
ushort custChangeAddr;
|
||||
if (pumpVersion == 2)
|
||||
{
|
||||
// Reuse the base address from the 0xC6 response.
|
||||
custChangeAddr = (ushort)(baseAddr >= 0x1D ? baseAddr - 0x1D : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
custChangeAddr = ReadCustomerChangeAddressNonV2(kwp);
|
||||
}
|
||||
string custChangeIndex = custChangeAddr != 0
|
||||
? ReadRomString(kwp, custChangeAddr, 6)
|
||||
: string.Empty;
|
||||
result[KlineKeys.ModelIndex] = custChangeIndex;
|
||||
|
||||
Report(65, "Reading DFI calibration value...");
|
||||
kwp.KeepAlive();
|
||||
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
|
||||
kwp.KeepAlive();
|
||||
@@ -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<byte> { 0x18, 0x00, 0x00, 0x82, 0x33 }); break;
|
||||
case 1: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x72, 0x53 }); break;
|
||||
case 2: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 }); break;
|
||||
}
|
||||
// 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<byte> { 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<byte> { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-aware variant of <see cref="ReadAllInfo"/>. Reuses the persistent
|
||||
/// K-Line session, skipping WakeUp and ReadEcuInfo. The session stays alive
|
||||
/// afterward (no EndCommunication).
|
||||
/// </summary>
|
||||
private Dictionary<string, string> ReadAllInfoWithSession(int pumpVersion, CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<string, string> { [KlineKeys.Result] = "0" };
|
||||
|
||||
_busLock.Wait(ct);
|
||||
try
|
||||
{
|
||||
var kwp = _sessionKwp!;
|
||||
|
||||
Report(20, "Reading pump data (session active)...");
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Unlock EEPROM for the given pump variant.
|
||||
if (pumpVersion == 2)
|
||||
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 });
|
||||
|
||||
// Version-specific session unlock.
|
||||
kwp.KeepAlive();
|
||||
switch (pumpVersion)
|
||||
{
|
||||
case 0: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x00, 0x82, 0x33 }); break;
|
||||
case 1: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x72, 0x53 }); break;
|
||||
case 2: kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x01, 0x53, 0x72 }); break;
|
||||
}
|
||||
|
||||
// Read the ROM base address once (0xC6 command).
|
||||
Report(40, "Reading pump identifier...");
|
||||
kwp.KeepAlive();
|
||||
ushort baseAddr = ReadBaseRomAddress(kwp);
|
||||
ushort identAddr = (ushort)(baseAddr >= 10 ? baseAddr - 10 : 0);
|
||||
string ident = identAddr != 0 ? ReadRomString(kwp, identAddr, 10) : string.Empty;
|
||||
result[KlineKeys.PumpId] = ident;
|
||||
|
||||
if (!string.IsNullOrEmpty(ident))
|
||||
PumpIdentified?.Invoke(ident);
|
||||
|
||||
Report(55, "Reading customer change index...");
|
||||
kwp.KeepAlive();
|
||||
ushort custChangeAddr;
|
||||
if (pumpVersion == 2)
|
||||
{
|
||||
custChangeAddr = (ushort)(baseAddr >= 0x1D ? baseAddr - 0x1D : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
custChangeAddr = ReadCustomerChangeAddressNonV2(kwp);
|
||||
}
|
||||
string custChangeIndex = custChangeAddr != 0
|
||||
? ReadRomString(kwp, custChangeAddr, 6)
|
||||
: string.Empty;
|
||||
result[KlineKeys.ModelIndex] = custChangeIndex;
|
||||
|
||||
Report(65, "Reading DFI calibration value...");
|
||||
kwp.KeepAlive();
|
||||
kwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
|
||||
kwp.KeepAlive();
|
||||
|
||||
var dfiPackets = kwp.SendCustom(new List<byte> { 0x19, 0x02, 0x00, 0x44 });
|
||||
double dfi = 0;
|
||||
foreach (var pkt in dfiPackets)
|
||||
{
|
||||
if (pkt is ReadEepromResponsePacket && pkt.Body.Count > 0)
|
||||
{ dfi = ((sbyte)pkt.Body[0] * 3.0) / 256.0; break; }
|
||||
}
|
||||
result[KlineKeys.Dfi] = dfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
DfiRead?.Invoke(dfi);
|
||||
|
||||
Report(75, "Reading serial number...");
|
||||
kwp.KeepAlive();
|
||||
string serial = ReadEepromString(kwp, new List<byte> { 0x19, 0x06, 0x00, 0x80 });
|
||||
result[KlineKeys.SerialNumber] = serial;
|
||||
|
||||
Report(85, "Reading fault codes...");
|
||||
kwp.KeepAlive();
|
||||
var faultCodes = kwp.ReadFaultCodes();
|
||||
result[KlineKeys.Errors] = faultCodes.Count > 0
|
||||
? string.Join(Environment.NewLine, faultCodes)
|
||||
: KlineKeys.NoErrors;
|
||||
|
||||
Report(90, "Enabling signal...");
|
||||
kwp.KeepAlive();
|
||||
kwp.SendCustom(new List<byte> { 0x00 });
|
||||
if (pumpVersion != 2)
|
||||
{
|
||||
kwp.SendCustom(new List<byte> { 0x02, 0x88, 0x01, 0x04, 0x06, 0x01 });
|
||||
}
|
||||
else
|
||||
{
|
||||
kwp.SendCustom(new List<byte> { 0x02, 0x55, 0x01, 0x04, 0x06, 0x01 });
|
||||
kwp.SendCustom(new List<byte> { 0x01, 0x02, 0x00, 0xC6 });
|
||||
for (int i = 0; i < 10; i++) kwp.KeepAlive();
|
||||
}
|
||||
kwp.KeepAlive();
|
||||
// No EndCommunication — keep session alive.
|
||||
|
||||
result[KlineKeys.Result] = "1";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
result[KlineKeys.ConnectError] = "Cancelled";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result[KlineKeys.ConnectError] = ex.Message;
|
||||
_log.Error(LogId, $"ReadAllInfo (session): {ex}");
|
||||
CleanupSession();
|
||||
SetState(KLineConnectionState.Failed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busLock.Release();
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -189,6 +464,9 @@ namespace HC_APTBS.Services.Impl
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> ReadFaultCodesAsync(string port, CancellationToken ct = default)
|
||||
{
|
||||
if (_kLineState == KLineConnectionState.Connected)
|
||||
return await Task.Run(() => ReadFaultCodesWithSession(ct), ct);
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
FtdiInterface? iface = null;
|
||||
@@ -222,6 +500,9 @@ namespace HC_APTBS.Services.Impl
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> ClearFaultCodesAsync(string port, CancellationToken ct = default)
|
||||
{
|
||||
if (_kLineState == KLineConnectionState.Connected)
|
||||
return await Task.Run(() => ClearFaultCodesWithSession(ct), ct);
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
FtdiInterface? iface = null;
|
||||
@@ -259,6 +540,9 @@ namespace HC_APTBS.Services.Impl
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> ReadDfiAsync(string port, CancellationToken ct = default)
|
||||
{
|
||||
if (_kLineState == KLineConnectionState.Connected)
|
||||
return await Task.Run(() => ReadDfiWithSession(ct), ct);
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
FtdiInterface? iface = null;
|
||||
@@ -297,13 +581,27 @@ namespace HC_APTBS.Services.Impl
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> WriteDfiAsync(string port, float dfi, int version, CancellationToken ct = default)
|
||||
{
|
||||
if (_kLineState == KLineConnectionState.Connected)
|
||||
return await Task.Run(() => WriteDfiWithSession(dfi, version, ct), ct);
|
||||
|
||||
return await Task.Run(() => WriteDfiInternal(port, dfi, version, closeSession: true), ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string> WriteDfiAndRestartAsync(string port, float dfi, int version, CancellationToken ct = default)
|
||||
{
|
||||
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<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
|
||||
_sessionKwp.KeepAlive();
|
||||
var packets = _sessionKwp.SendCustom(new List<byte> { 0x19, 0x02, 0x00, 0x44 });
|
||||
double dfi = 0;
|
||||
foreach (var pkt in packets)
|
||||
if (pkt is ReadEepromResponsePacket && pkt.Body.Count > 0)
|
||||
{ dfi = ((sbyte)pkt.Body[0] * 3.0) / 256.0; break; }
|
||||
_sessionKwp.KeepAlive();
|
||||
Report(100, "Done.");
|
||||
return dfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(LogId, $"ReadDfi (session): {ex.Message}");
|
||||
CleanupSession();
|
||||
SetState(KLineConnectionState.Failed);
|
||||
return "0";
|
||||
}
|
||||
finally { _busLock.Release(); }
|
||||
}
|
||||
|
||||
private string WriteDfiWithSession(float dfi, int version, CancellationToken ct)
|
||||
{
|
||||
_busLock.Wait(ct);
|
||||
double newDfi = 0;
|
||||
try
|
||||
{
|
||||
var passPacket = version switch
|
||||
{
|
||||
1 => new List<byte> { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x30, 0x35, 0x30, 0x30, 0x30, 0x31, 0x1C, 0x09, 0x04 },
|
||||
2 or 3 => new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xF2, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 },
|
||||
_ => new List<byte> { 0x18, 0x00, 0x03, 0x2F, 0xFF, 0x4B, 0x48, 0x54, 0x43, 0x41, 0x38, 0x47, 0x30, 0x45 }
|
||||
};
|
||||
|
||||
Report(30, "Authenticating and writing DFI...");
|
||||
_sessionKwp!.KeepAlive();
|
||||
_sessionKwp.SendCustom(passPacket);
|
||||
_sessionKwp.KeepAlive();
|
||||
|
||||
sbyte rawValue = (sbyte)((dfi * 256.0f) / 3.0f);
|
||||
if (rawValue == 0) rawValue = 1;
|
||||
byte checksum = (byte)(0 - (byte)rawValue);
|
||||
|
||||
_sessionKwp.SendCustom(new List<byte> { 0x1A, 0x02, 0x00, 0x44, (byte)rawValue, checksum, 0x03 });
|
||||
_sessionKwp.KeepAlive();
|
||||
|
||||
Report(60, "Verifying write...");
|
||||
_sessionKwp.SendCustom(new List<byte> { 0x18, 0x00, 0x03, 0xFF, 0xFF });
|
||||
_sessionKwp.KeepAlive();
|
||||
var packets = _sessionKwp.SendCustom(new List<byte> { 0x19, 0x02, 0x00, 0x44 });
|
||||
foreach (var pkt in packets)
|
||||
if (pkt is ReadEepromResponsePacket && pkt.Body.Count > 0)
|
||||
{ newDfi = ((sbyte)pkt.Body[0] * 3.0) / 256.0; break; }
|
||||
|
||||
_sessionKwp.KeepAlive();
|
||||
Report(100, "Done.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(LogId, $"WriteDfi (session): {ex.Message}");
|
||||
CleanupSession();
|
||||
SetState(KLineConnectionState.Failed);
|
||||
}
|
||||
finally { _busLock.Release(); }
|
||||
|
||||
return newDfi.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private string WriteDfiInternal(string port, float dfi, int version, bool closeSession)
|
||||
@@ -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<byte> { 0x01, 0x02, 0x00, 0xC6 });
|
||||
foreach (var pkt in packets)
|
||||
if (pkt.Body.Count > 1)
|
||||
return (ushort)(((pkt.Body[1] << 8) | pkt.Body[0]) - 0x1D);
|
||||
return 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var data = kwp.ReadRomEeprom(0x9FFE, 2);
|
||||
if (data == null || data.Count < 2) return 0;
|
||||
return (ushort)(((data[1] << 8) | data[0]) + 3);
|
||||
}
|
||||
}
|
||||
|
||||
private ushort ReadIdentAddress(KW1281Connection kwp)
|
||||
/// <summary>
|
||||
/// Sends the ROM address lookup command {0x01, 0x02, 0x00, 0xC6} once and
|
||||
/// returns the raw 16-bit base address. Both the pump identifier (base − 10)
|
||||
/// and the V2 customer-change index (base − 0x1D) derive from this value.
|
||||
/// </summary>
|
||||
private ushort ReadBaseRomAddress(KW1281Connection kwp)
|
||||
{
|
||||
var packets = kwp.SendCustom(new List<byte> { 0x01, 0x02, 0x00, 0xC6 });
|
||||
foreach (var pkt in packets)
|
||||
if (pkt.Body.Count > 1)
|
||||
return (ushort)(((pkt.Body[1] << 8) | pkt.Body[0]) - 10);
|
||||
return (ushort)((pkt.Body[1] << 8) | pkt.Body[0]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the customer-change ROM address for non-V2 pumps using
|
||||
/// the legacy ROM pointer at 0x9FFE.
|
||||
/// </summary>
|
||||
private ushort ReadCustomerChangeAddressNonV2(KW1281Connection kwp)
|
||||
{
|
||||
var data = kwp.ReadRomEeprom(0x9FFE, 2);
|
||||
if (data == null || data.Count < 2) return 0;
|
||||
return (ushort)(((data[1] << 8) | data[0]) + 3);
|
||||
}
|
||||
|
||||
private string ReadRomString(KW1281Connection kwp, ushort address, byte count)
|
||||
{
|
||||
var data = kwp.ReadRomEeprom(address, count);
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
/// <summary>Auxiliary temperature T4 (°C).</summary>
|
||||
[ObservableProperty] private double _temp4;
|
||||
|
||||
/// <summary>Oil tank temperature (°C).</summary>
|
||||
[ObservableProperty] private double _benchTemp;
|
||||
|
||||
/// <summary>Fuel delivery measurement Q-delivery (cc/stroke).</summary>
|
||||
[ObservableProperty] private double _qDelivery;
|
||||
|
||||
/// <summary>Fuel overflow/pilot measurement Q-over (cc/stroke).</summary>
|
||||
[ObservableProperty] private double _qOver;
|
||||
|
||||
/// <summary>Bench oil pressure (bar).</summary>
|
||||
/// <summary>Bench oil pressure P1 (bar), sensor-calibrated.</summary>
|
||||
[ObservableProperty] private double _pressure;
|
||||
|
||||
/// <summary>Analogue sensor 2 pressure P2 (bar), sensor-calibrated.</summary>
|
||||
[ObservableProperty] private double _pressure2;
|
||||
|
||||
/// <summary>PSG encoder position value.</summary>
|
||||
[ObservableProperty] private double _psgEncoderValue;
|
||||
|
||||
@@ -316,8 +328,8 @@ namespace HC_APTBS.ViewModels
|
||||
/// <summary>True when oil circulation has been detected.</summary>
|
||||
[ObservableProperty] private bool _isOilCirculating;
|
||||
|
||||
/// <summary>True when a K-Line session is active.</summary>
|
||||
[ObservableProperty] private bool _isKLineConnected;
|
||||
/// <summary>Current K-Line session state (Disconnected / Connected / Failed).</summary>
|
||||
[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,
|
||||
|
||||
@@ -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
|
||||
/// <summary>True while a K-Line read is in progress.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ReadKlineCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DisconnectKLineCommand))]
|
||||
private bool _isReading;
|
||||
|
||||
/// <summary>Current K-Line session state for Disconnect button enable/disable.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(DisconnectKLineCommand))]
|
||||
private KLineConnectionState _kLineState = KLineConnectionState.Disconnected;
|
||||
|
||||
/// <summary>DFI calibration angle read from ECU EEPROM.</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Closes the persistent K-Line session.</summary>
|
||||
[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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -23,6 +23,9 @@ namespace HC_APTBS.ViewModels
|
||||
/// <summary>Chart title label.</summary>
|
||||
[ObservableProperty] private string _title = string.Empty;
|
||||
|
||||
/// <summary>Most recent sample value, displayed as a numeric label alongside the chart.</summary>
|
||||
[ObservableProperty] private double _currentValue;
|
||||
|
||||
/// <summary>Series array bound to the CartesianChart.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ namespace HC_APTBS.ViewModels
|
||||
get => _isActive;
|
||||
set => SetProperty(ref _isActive, value);
|
||||
}
|
||||
|
||||
/// <summary>Zero-based bit position (0–15) shown as a label beneath the indicator.</summary>
|
||||
public int Index { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="{Binding Title}" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="DimGray" Margin="4,0"/>
|
||||
<StackPanel Orientation="Horizontal" Margin="4,0">
|
||||
<TextBlock Text="{Binding Title}" FontSize="11" FontWeight="SemiBold"
|
||||
Foreground="DimGray"/>
|
||||
<TextBlock Text="{Binding CurrentValue, StringFormat=F3}" FontSize="14" FontWeight="Bold"
|
||||
Foreground="#4080FF" FontFamily="Consolas" Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<lvc:CartesianChart Grid.Row="1" Height="120"
|
||||
Series="{Binding Series}"
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0">
|
||||
<Button Content="Read K-Line" Width="90" Margin="0,2"
|
||||
Command="{Binding ReadKlineCommand}"/>
|
||||
<Button Content="Disconnect" Width="90" Margin="0,2"
|
||||
Command="{Binding DisconnectKLineCommand}"/>
|
||||
<!-- Progress bar shown during K-Line read -->
|
||||
<ProgressBar Value="{Binding ProgressPercent, Mode=OneWay}"
|
||||
Minimum="0" Maximum="100" Height="6"
|
||||
|
||||
@@ -44,8 +44,11 @@
|
||||
Width="12" Height="12"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
ToolTip="{Binding Description}"
|
||||
ToolTipService.InitialShowDelay="150"
|
||||
ToolTipService.ShowDuration="30000"
|
||||
SnapsToDevicePixels="True"/>
|
||||
<TextBlock Grid.Row="1" FontSize="8"
|
||||
Text="{Binding Index}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="DimGray"/>
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user