Files
HC_APTBS/Infrastructure/Pcan/PcanAdapter.cs
LucianoDev d9775b48be feat: chart grid, pressure tolerance bands, QDelivery RPM normalization, pump page polish
Charts
- Add faint background grid (0.75px, #E0E0E0) to all live charts; matches PDF report style
- Show min/max tolerance bands on P1/P2 pressure charts during test runs (previously only Q-Delivery/Q-Over)
- Broaden BenchService.ToleranceUpdated to fire for every phase receive; UI routes by name
- Clear P1/P2 traces on PhaseChanged alongside Delivery/Over

CAN
- Normalize QDelivery flow rate to 1000 RPM reference before IIR filter so RPM spikes are low-pass filtered with flow-rate transients (matches old_source behavior)

Pump page
- Reorder columns: identification left, commands center, live data right
- PreIn control always visible; disabled when pump lacks pre-injection (rename IsPreInVisible -> IsPreInAvailable)
- Swap value/label order in command cards
- Remove redundant KlineErrors row from identification card

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:42:30 +02:00

692 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using HC_APTBS.Infrastructure.Logging;
using HC_APTBS.Models;
using HC_APTBS.Services;
using Peak.Can.Basic;
using TPCANHandle = System.UInt16;
namespace HC_APTBS.Infrastructure.Pcan
{
/// <summary>
/// Wraps the PCAN-Basic native API behind <see cref="ICanService"/>.
/// The raw P/Invoke declarations live in <see cref="PcanBasic"/> (vendor file,
/// unchanged). This class handles lifecycle, threading, OEM legitimation,
/// message dispatch, and parameter decoding.
/// </summary>
public sealed class PcanAdapter : ICanService, IDisposable
{
// ── Constants ───────────────────────────────────────────────────────────
/// <summary>PEAK-System distributor code embedded into the OEM legitimation token.</summary>
private const ulong OemDistributorCode = 20120378UL;
/// <summary>PEAK-System OEM-ID for the PCAN-USB device.</summary>
private const ulong OemId = 21200UL;
private const string LogId = "PcanAdapter";
// ── State ────────────────────────────────────────────────────────────────
private TPCANHandle _channel;
private TPCANBaudrate _baudrate;
private readonly IAppLogger _log;
/// <summary>
/// Live parameter map: CAN message ID → list of parameters decoded from that frame.
/// All reads/writes on this dictionary happen on the CAN read thread, except for
/// <see cref="AddParameters"/> / <see cref="RemoveParameters"/> which are guarded
/// by <see cref="_mapLock"/>.
/// </summary>
private Dictionary<uint, List<CanBusParameter>> _parameterMap = new();
private readonly object _mapLock = new();
// Cached reference to the bench RPM parameter, re-resolved on every SetParameters /
// AddParameters call. Used to normalize QDelivery (flow rate) against shaft speed
// before the IIR low-pass filter runs, so that RPM spikes are filtered alongside
// flow-rate transients rather than bleeding into the displayed value unfiltered.
// Matches old_source behavior (Herlic2.0/MainWindow.xaml.cs:656, 1874).
private CanBusParameter? _benchRpmParam;
private const double QDeliveryReferenceRpm = 1000.0;
private const double QDeliveryMinRpm = 1.0;
private Thread? _readThread;
private AutoResetEvent? _receiveEvent;
private volatile bool _stopRead = true;
// ── Liveness tracking ────────────────────────────────────────────────────
private const int LivenessTimeoutMs = 500;
private HashSet<uint> _benchMessageIds = new();
private HashSet<uint> _pumpMessageIds = new();
private DateTime _lastBenchFrameUtc = DateTime.MinValue;
private DateTime _lastPumpFrameUtc = DateTime.MinValue;
private bool _benchAlive;
private bool _pumpAlive;
// ── ICanService ──────────────────────────────────────────────────────────
/// <inheritdoc/>
public event Action<string, bool>? StatusChanged;
/// <inheritdoc/>
public event Action<bool>? BenchLivenessChanged;
/// <inheritdoc/>
public event Action<bool>? PumpLivenessChanged;
/// <inheritdoc/>
public TPCANStatus CurrentStatus { get; private set; } = TPCANStatus.PCAN_ERROR_OK;
/// <inheritdoc/>
public bool IsConnected => !_stopRead;
/// <inheritdoc/>
public TPCANHandle SelectedChannel
{
get => _channel;
set
{
if (IsConnected)
throw new System.InvalidOperationException(
"Cannot change the CAN channel while connected. Call Disconnect() first.");
_channel = value;
}
}
// ── Construction ─────────────────────────────────────────────────────────
/// <summary>
/// Creates a new adapter for the given PCAN channel and baudrate.
/// Call <see cref="Connect"/> to open the hardware.
/// </summary>
/// <param name="channel">PCAN channel handle, e.g. <c>PCANBasic.PCAN_USBBUS1</c>.</param>
/// <param name="baudrate">CAN baudrate, e.g. <c>TPCANBaudrate.PCAN_BAUD_500K</c>.</param>
/// <param name="logger">Application logger.</param>
public PcanAdapter(TPCANHandle channel, TPCANBaudrate baudrate, IAppLogger logger)
{
_channel = channel;
_baudrate = baudrate;
_log = logger;
}
// ── ICanService: discovery ────────────────────────────────────────────────
/// <inheritdoc/>
public System.Collections.Generic.IReadOnlyList<AttachedPcanChannel> EnumerateAttachedChannels()
{
var result = new System.Collections.Generic.List<AttachedPcanChannel>();
try
{
var countStatus = PCANBasic.GetValue(
PCANBasic.PCAN_NONEBUS,
TPCANParameter.PCAN_ATTACHED_CHANNELS_COUNT,
out uint count,
sizeof(uint));
if (countStatus != TPCANStatus.PCAN_ERROR_OK || count == 0)
return result;
var buffer = new TPCANChannelInformation[count];
var infoStatus = PCANBasic.GetValue(
PCANBasic.PCAN_NONEBUS,
TPCANParameter.PCAN_ATTACHED_CHANNELS,
buffer);
if (infoStatus != TPCANStatus.PCAN_ERROR_OK)
return result;
foreach (var ch in buffer)
{
if (ch.device_type == TPCANDevice.PCAN_USB)
result.Add(new AttachedPcanChannel(ch.channel_handle, ch.device_name ?? $"PCAN-USB ({ch.channel_handle:X})"));
}
}
catch (Exception ex)
{
_log.Warning(LogId, $"EnumerateAttachedChannels failed: {ex.Message}");
}
return result;
}
// ── ICanService: lifecycle ────────────────────────────────────────────────
/// <inheritdoc/>
public bool Connect()
{
try
{
// If the channel is already open, reuse it; otherwise initialize.
CurrentStatus = PCANBasic.GetStatus(_channel);
if (CurrentStatus == TPCANStatus.PCAN_ERROR_INITIALIZE ||
CurrentStatus == TPCANStatus.PCAN_ERROR_INITIALIZE2)
{
CurrentStatus = PCANBasic.Initialize(_channel, _baudrate, (TPCANType)0, 0, 0);
EmitStatusChanged(CurrentStatus);
}
if (CurrentStatus == TPCANStatus.PCAN_ERROR_NETINUSE)
{
_log.Error(LogId, "CAN channel is already in use by another application.");
EmitStatusChanged(CurrentStatus);
return false;
}
if (CurrentStatus != TPCANStatus.PCAN_ERROR_OK)
{
LogPcanError("Connect: initialization failed");
EmitStatusChanged(CurrentStatus);
return false;
}
// OEM legitimation: token = (DistributorCode << 32) | OemId
// This authenticates our application with the PCAN hardware.
ulong token = (OemDistributorCode << 32) | OemId;
CurrentStatus = PCANBasic.SetValue(
_channel, TPCANParameter.PCAN_CHANNEL_LEGITIMATION, ref token, 8);
if (CurrentStatus != TPCANStatus.PCAN_ERROR_OK)
{
_log.Error(LogId, "OEM legitimation failed — adapter has no OEM token.");
PCANBasic.Uninitialize(_channel);
EmitStatusChanged(CurrentStatus);
return false;
}
StartReadThread();
return true;
}
catch (Exception ex)
{
_log.Error(LogId, $"Connect exception: {ex}");
return false;
}
}
/// <inheritdoc/>
public void Disconnect()
{
_stopRead = true;
PCANBasic.Uninitialize(_channel);
}
/// <inheritdoc/>
public void SwitchBaudrate(TPCANBaudrate newBaudrate, uint baudrateMessageId)
{
// Send the baudrate-change command to the bench firmware before switching.
SendMessageById(baudrateMessageId);
_stopRead = true;
Thread.Sleep(250);
_baudrate = newBaudrate;
PCANBasic.Uninitialize(_channel);
PCANBasic.Reset(_channel);
Connect();
}
// ── ICanService: parameter map ────────────────────────────────────────────
/// <inheritdoc/>
public void SetParameters(Dictionary<uint, List<CanBusParameter>> parameters)
{
lock (_mapLock)
{
_parameterMap = parameters;
ResolveBenchRpmParam();
}
}
/// <inheritdoc/>
public void AddParameters(Dictionary<uint, List<CanBusParameter>> parameters)
{
lock (_mapLock)
{
foreach (var kv in parameters)
{
if (!_parameterMap.ContainsKey(kv.Key))
_parameterMap.Add(kv.Key, kv.Value);
}
ResolveBenchRpmParam();
}
}
// Call under _mapLock.
private void ResolveBenchRpmParam()
{
_benchRpmParam = null;
foreach (var list in _parameterMap.Values)
{
foreach (var p in list)
{
if (p.Name == BenchParameterNames.BenchRpm)
{
_benchRpmParam = p;
return;
}
}
}
}
/// <inheritdoc/>
public void RemoveParameters(Dictionary<uint, List<CanBusParameter>> parameters)
{
lock (_mapLock)
{
foreach (var key in parameters.Keys)
_parameterMap.Remove(key);
}
}
/// <inheritdoc/>
public void RegisterBenchMessageIds(IReadOnlyCollection<uint> ids)
{
_benchMessageIds = new HashSet<uint>(ids);
}
/// <inheritdoc/>
public void RegisterPumpMessageIds(IReadOnlyCollection<uint> ids)
{
_pumpMessageIds = new HashSet<uint>(ids);
}
// ── ICanService: transmit ─────────────────────────────────────────────────
/// <inheritdoc/>
public void SendMessageById(uint messageId)
{
Dictionary<uint, List<CanBusParameter>> snapshot;
lock (_mapLock) { snapshot = _parameterMap; }
if (!snapshot.TryGetValue(messageId, out var parameters) || parameters.Count == 0)
return;
var msg = new TPCANMsg
{
ID = messageId,
LEN = 8,
MSGTYPE = TPCANMessageType.PCAN_MESSAGE_STANDARD,
DATA = new byte[8]
};
// Write only transmit (non-receive) parameters into their assigned byte positions.
foreach (var param in parameters)
{
if (param.IsReceive) continue;
// Cast to int first so negative values (e.g. FBKW) get proper
// two's complement representation in the 16-bit CAN field.
ushort raw = unchecked((ushort)(int)param.GetTransmitValue());
msg.DATA[param.ByteH] = (byte)(raw >> 8);
msg.DATA[param.ByteL] = (byte)(raw & 0xFF);
}
CurrentStatus = PCANBasic.Write(_channel, ref msg);
if (CurrentStatus != TPCANStatus.PCAN_ERROR_OK)
_log.Warning(LogId, $"SendMessageById({messageId:X}): {CurrentStatus}");
}
/// <inheritdoc/>
public void SendRawMessage(uint messageId, byte[] data)
{
if (data.Length != 8)
throw new ArgumentException("CAN standard frame payload must be exactly 8 bytes.", nameof(data));
var msg = new TPCANMsg
{
ID = messageId,
LEN = 8,
MSGTYPE = TPCANMessageType.PCAN_MESSAGE_STANDARD,
DATA = data
};
CurrentStatus = PCANBasic.Write(_channel, ref msg);
if (CurrentStatus != TPCANStatus.PCAN_ERROR_OK)
_log.Warning(LogId, $"SendRawMessage({messageId:X}): {CurrentStatus}");
}
// ── Read thread ───────────────────────────────────────────────────────────
private void StartReadThread()
{
_stopRead = false;
_receiveEvent = new AutoResetEvent(false);
_readThread = new Thread(ReadThreadEntry) { IsBackground = true, Name = "CAN-Read" };
_readThread.Start();
}
private void ReadThreadEntry()
{
// Bind the AutoResetEvent handle to the PCAN receive event so the driver
// signals us whenever a new frame arrives in the hardware FIFO.
uint eventHandle = Convert.ToUInt32(_receiveEvent!.SafeWaitHandle.DangerousGetHandle().ToInt32());
var result = PCANBasic.SetValue(
_channel, TPCANParameter.PCAN_RECEIVE_EVENT, ref eventHandle, sizeof(uint));
if (result != TPCANStatus.PCAN_ERROR_OK)
{
_log.Error(LogId, $"Failed to bind receive event: {result}");
return;
}
DrainMessageQueue();
}
/// <summary>
/// Continuously drains the hardware receive FIFO until stopped.
/// Runs on the dedicated CAN read background thread.
/// </summary>
private void DrainMessageQueue()
{
while (!_stopRead)
{
var status = ReadOnce();
if (status == TPCANStatus.PCAN_ERROR_ILLOPERATION)
{
_log.Error(LogId, "Read thread: illegal operation — stopping.");
_stopRead = true;
break;
}
if (status != TPCANStatus.PCAN_ERROR_QRCVEMPTY && status != TPCANStatus.PCAN_ERROR_OK)
{
_log.Warning(LogId, $"DrainMessageQueue: {status}");
EmitStatusChanged(status);
}
// Check liveness timeouts.
CheckLivenessTimeout();
// Configurable polling interval to avoid pegging the CPU.
// Typical value: 250 ms depending on operational phase.
Thread.Sleep(2);
}
}
/// <summary>
/// Reads and decodes all frames currently available in the hardware FIFO.
/// Returns the last <see cref="TPCANStatus"/> encountered.
/// </summary>
private TPCANStatus ReadOnce()
{
TPCANStatus status;
do
{
status = PCANBasic.Read(_channel, out TPCANMsg frame);
if (status != TPCANStatus.PCAN_ERROR_QRCVEMPTY)
DecodeFrame(frame);
if (status != TPCANStatus.PCAN_ERROR_QRCVEMPTY && status != TPCANStatus.PCAN_ERROR_OK)
{
_log.Warning(LogId, $"ReadOnce: {status}");
EmitStatusChanged(status);
}
}
while (status != TPCANStatus.PCAN_ERROR_QRCVEMPTY &&
status != TPCANStatus.PCAN_ERROR_BUSHEAVY);
return status;
}
// ── Frame decoding ────────────────────────────────────────────────────────
/// <summary>
/// Decodes a received CAN frame and updates the associated <see cref="CanBusParameter"/>
/// values, applying the calibration transfer function and exponential smoothing filter.
/// </summary>
private void DecodeFrame(TPCANMsg frame)
{
// Message ID 0 carries internal bus-status info — ignore.
if (frame.ID == 0) return;
Dictionary<uint, List<CanBusParameter>> snapshot;
lock (_mapLock) { snapshot = _parameterMap; }
if (!snapshot.TryGetValue(frame.ID, out var parameters)) return;
// Track liveness for bench and pump frame groups.
var now = DateTime.UtcNow;
if (_benchMessageIds.Contains(frame.ID))
{
_lastBenchFrameUtc = now;
if (!_benchAlive)
{
_benchAlive = true;
BenchLivenessChanged?.Invoke(true);
}
}
if (_pumpMessageIds.Contains(frame.ID))
{
_lastPumpFrameUtc = now;
if (!_pumpAlive)
{
_pumpAlive = true;
PumpLivenessChanged?.Invoke(true);
}
}
byte[] data = frame.DATA;
foreach (var param in parameters)
{
// Only decode receive parameters — skip send-only params to avoid
// overwriting outgoing values with received frame bytes.
if (!param.IsReceive) continue;
double previousValue = param.Value;
if (param.Name == PumpParameterNames.Temp)
{
// 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)
{
// RPM is packed in the upper 12 bits across two bytes (two encoding variants).
param.Value = DecodeRpmValue(data, param);
param.Value = param.GetTransformResult();
}
else
{
// Generic 1-byte, 2-byte, or 3-byte big-endian integer.
int byteSpan = Math.Abs(param.ByteH - param.ByteL);
double rawValue;
if (byteSpan == 0)
{
rawValue = data[param.ByteL];
}
else if (byteSpan == 1)
{
rawValue = (data[param.ByteH] << 8) | data[param.ByteL];
}
else
{
// 3-byte little-endian variant used for encoder/pulse counters.
rawValue = (data[param.ByteL + 2] << 16)
| (data[param.ByteL + 1] << 8)
| data[param.ByteL];
}
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;
}
// Spike rejection for BenchRPM: the bench controller occasionally sends
// a spurious value of 1 RPM — discard it and retain the previous value.
if (param.Name == BenchParameterNames.BenchRpm && param.Value == 1)
{
param.Value = previousValue;
return;
}
// Spike rejection for QDelivery: discard values that are more than
// 100x the previous raw reading (caused by relay switching noise).
// Compare against the previous raw-normalized value below.
if (param.Name == BenchParameterNames.QDelivery)
{
// Normalize raw flow rate to a 1000 RPM reference BEFORE filtering,
// so that RPM spikes are low-pass filtered together with flow-rate
// transients rather than appearing as instantaneous jumps in the
// normalized output.
double rpm = _benchRpmParam?.Value ?? 0;
double normalized = rpm < QDeliveryMinRpm
? 0
: param.Value * (QDeliveryReferenceRpm / rpm);
if (previousValue > 0.1 && normalized > previousValue * 100)
{
_log.Warning(LogId,
$"QDelivery spike suppressed: prev={previousValue:F3}, new={normalized:F3}");
normalized = previousValue;
}
param.Value = normalized;
}
// Apply single-pole IIR low-pass filter.
// result = prev + alpha * (new - prev)
param.Value = PassFilterUpdate(previousValue, param.Value, param.Alpha);
param.NeedsUpdate = true;
}
}
/// <summary>
/// Decodes a temperature value from the CAN frame.
/// The bench uses three different encoding schemes identified by <c>param.Type</c>:
/// <list type="bullet">
/// <item>0 — 4-nibble BCD with sign extension: [256…+256] + fractional nibble</item>
/// <item>1 — Compact signed: [54…+∞] with 1/16 fractional resolution</item>
/// <item>2 — Kelvin raw integer: value = (raw 273.15) via calibration transform</item>
/// </list>
/// </summary>
private double DecodeTempValue(byte[] data, CanBusParameter param)
{
switch (param.Type)
{
case 0:
{
// Full BCD signed: high byte nibbles encode hundreds/tens, low byte encodes units/fraction.
double val = -256
+ 256 * ((data[param.ByteH] & 0xF0) >> 4)
+ -16
+ 16 * (data[param.ByteH] & 0x0F)
+ ((data[param.ByteL] & 0xF0) >> 4);
val += (data[param.ByteL] & 0x0F) * (1.0 / 16.0);
return val;
}
case 1:
{
// Compact signed encoding.
double val = -54
+ 16 * ((data[param.ByteH] & 0xF0) >> 4)
+ -1
+ (data[param.ByteH] & 0x0F);
val += ((data[param.ByteL] & 0xF0) >> 4) * (1.0 / 16.0);
return val;
}
case 2:
{
// Raw Kelvin integer, converted via calibration formula (subtracts 273.15).
param.Value = (data[param.ByteH] << 8) | data[param.ByteL];
return param.GetTransformResult() - 273.15;
}
default:
return 0;
}
}
/// <summary>
/// Decodes an RPM value from the CAN frame.
/// Two encoding variants exist depending on <c>param.Type</c>:
/// <list type="bullet">
/// <item>0/1 — Upper 12 bits: ByteH shifted left 4, ByteL shifted right 4</item>
/// <item>2 — Lower 12 bits: lower nibble of ByteH as upper bits, ByteL as lower byte</item>
/// </list>
/// </summary>
private static double DecodeRpmValue(byte[] data, CanBusParameter param)
{
int raw = param.Type switch
{
0 or 1 => (data[param.ByteH] << 4) | (data[param.ByteL] >> 4),
2 => ((data[param.ByteH] & 0x0F) << 8) | data[param.ByteL],
_ => 0
};
return raw;
}
/// <summary>
/// Checks if bench or pump frame reception has timed out and fires
/// liveness events on transition from alive to dead.
/// </summary>
private void CheckLivenessTimeout()
{
var now = DateTime.UtcNow;
if (_benchAlive && (now - _lastBenchFrameUtc).TotalMilliseconds > LivenessTimeoutMs)
{
_benchAlive = false;
BenchLivenessChanged?.Invoke(false);
}
if (_pumpAlive && (now - _lastPumpFrameUtc).TotalMilliseconds > LivenessTimeoutMs)
{
_pumpAlive = false;
PumpLivenessChanged?.Invoke(false);
}
}
// ── IIR low-pass filter ───────────────────────────────────────────────────
/// <summary>
/// Single-pole exponential moving average: result = prev + alpha * (value prev).
/// Rounds to 4 decimal places to avoid floating-point drift accumulation.
/// </summary>
private static double PassFilterUpdate(double prev, double value, double alpha)
=> Math.Round(prev + alpha * (value - prev), 4);
// ── Helpers ───────────────────────────────────────────────────────────────
private void EmitStatusChanged(TPCANStatus status)
{
string text = status.ToString();
// Strip the "PCAN_ERROR_" prefix for brevity in the UI status bar.
string shortText = text.StartsWith("PCAN_ERROR_", StringComparison.Ordinal)
? text[11..]
: text;
StatusChanged?.Invoke(shortText, status == TPCANStatus.PCAN_ERROR_OK);
}
private void LogPcanError(string context)
{
var sb = new StringBuilder(256);
PCANBasic.GetErrorText(CurrentStatus, 0, sb);
_log.Error(LogId, $"{context}: {CurrentStatus} — {sb}");
}
// ── IDisposable ───────────────────────────────────────────────────────────
/// <summary>Stops the read thread and releases the PCAN channel.</summary>
public void Dispose()
{
Disconnect();
_receiveEvent?.Dispose();
}
}
}