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
{
///
/// Wraps the PCAN-Basic native API behind .
/// The raw P/Invoke declarations live in (vendor file,
/// unchanged). This class handles lifecycle, threading, OEM legitimation,
/// message dispatch, and parameter decoding.
///
public sealed class PcanAdapter : ICanService, IDisposable
{
// ── Constants ───────────────────────────────────────────────────────────
/// PEAK-System distributor code embedded into the OEM legitimation token.
private const ulong OemDistributorCode = 20120378UL;
/// PEAK-System OEM-ID for the PCAN-USB device.
private const ulong OemId = 21200UL;
private const string LogId = "PcanAdapter";
// ── State ────────────────────────────────────────────────────────────────
private TPCANHandle _channel;
private TPCANBaudrate _baudrate;
private readonly IAppLogger _log;
///
/// 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
/// / which are guarded
/// by .
///
private Dictionary> _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 _benchMessageIds = new();
private HashSet _pumpMessageIds = new();
private DateTime _lastBenchFrameUtc = DateTime.MinValue;
private DateTime _lastPumpFrameUtc = DateTime.MinValue;
// volatile so IsPumpAlive/IsBenchAlive getters on other threads see transitions
// without relying on the memory-model guarantees of the event handler path.
private volatile bool _benchAlive;
private volatile bool _pumpAlive;
// ── ICanService ──────────────────────────────────────────────────────────
///
public event Action? StatusChanged;
///
public event Action? BenchLivenessChanged;
///
public event Action? PumpLivenessChanged;
///
public TPCANStatus CurrentStatus { get; private set; } = TPCANStatus.PCAN_ERROR_OK;
///
public bool IsConnected => !_stopRead;
///
public bool IsPumpAlive => _pumpAlive;
///
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 ─────────────────────────────────────────────────────────
///
/// Creates a new adapter for the given PCAN channel and baudrate.
/// Call to open the hardware.
///
/// PCAN channel handle, e.g. PCANBasic.PCAN_USBBUS1.
/// CAN baudrate, e.g. TPCANBaudrate.PCAN_BAUD_500K.
/// Application logger.
public PcanAdapter(TPCANHandle channel, TPCANBaudrate baudrate, IAppLogger logger)
{
_channel = channel;
_baudrate = baudrate;
_log = logger;
}
// ── ICanService: discovery ────────────────────────────────────────────────
///
public System.Collections.Generic.IReadOnlyList EnumerateAttachedChannels()
{
var result = new System.Collections.Generic.List();
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 ────────────────────────────────────────────────
///
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;
}
}
///
public void Disconnect()
{
_stopRead = true;
PCANBasic.Uninitialize(_channel);
}
///
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 ────────────────────────────────────────────
///
public void SetParameters(Dictionary> parameters)
{
lock (_mapLock)
{
_parameterMap = parameters;
ResolveBenchRpmParam();
}
}
///
public void AddParameters(Dictionary> parameters)
{
lock (_mapLock)
{
// Replace-on-conflict: callers may re-register on pump switch; the
// new pump's parameter objects must take precedence over any stale
// objects from the previous pump that shared CAN IDs.
foreach (var kv in parameters)
_parameterMap[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;
}
}
}
}
///
public void RemoveParameters(Dictionary> parameters)
{
lock (_mapLock)
{
foreach (var key in parameters.Keys)
_parameterMap.Remove(key);
}
}
///
public void RegisterBenchMessageIds(IReadOnlyCollection ids)
{
_benchMessageIds = new HashSet(ids);
}
///
public void RegisterPumpMessageIds(IReadOnlyCollection ids)
{
_pumpMessageIds = new HashSet(ids);
}
// ── ICanService: transmit ─────────────────────────────────────────────────
///
public void SendMessageById(uint messageId)
{
Dictionary> 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}");
}
///
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();
}
///
/// Continuously drains the hardware receive FIFO until stopped.
/// Runs on the dedicated CAN read background thread.
///
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: 2–50 ms depending on operational phase.
Thread.Sleep(2);
}
}
///
/// Reads and decodes all frames currently available in the hardware FIFO.
/// Returns the last encountered.
///
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 ────────────────────────────────────────────────────────
///
/// Decodes a received CAN frame and updates the associated
/// values, applying the calibration transfer function and exponential smoothing filter.
///
private void DecodeFrame(TPCANMsg frame)
{
// Message ID 0 carries internal bus-status info — ignore.
if (frame.ID == 0) return;
Dictionary> 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;
// Notify observers (e.g. UnlockService) that the decoded value changed.
// The filter rounds to 4 decimals so this does not fire on float noise.
if (param.Value != previousValue)
param.RaiseValueChanged();
}
}
///
/// Decodes a temperature value from the CAN frame.
/// The bench uses three different encoding schemes identified by param.Type:
///
/// - 0 — 4-nibble BCD with sign extension: [−256…+256] + fractional nibble
/// - 1 — Compact signed: [−54…+∞] with 1/16 fractional resolution
/// - 2 — Kelvin raw integer: value = (raw − 273.15) via calibration transform
///
///
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;
}
}
///
/// Decodes an RPM value from the CAN frame.
/// Two encoding variants exist depending on param.Type:
///
/// - 0/1 — Upper 12 bits: ByteH shifted left 4, ByteL shifted right 4
/// - 2 — Lower 12 bits: lower nibble of ByteH as upper bits, ByteL as lower byte
///
///
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;
}
///
/// Checks if bench or pump frame reception has timed out and fires
/// liveness events on transition from alive to dead.
///
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 ───────────────────────────────────────────────────
///
/// Single-pole exponential moving average: result = prev + alpha * (value − prev).
/// Rounds to 4 decimal places to avoid floating-point drift accumulation.
///
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 ───────────────────────────────────────────────────────────
/// Stops the read thread and releases the PCAN channel.
public void Dispose()
{
Disconnect();
_receiveEvent?.Dispose();
}
}
}