Clean up CorsairPsu

This commit is contained in:
PhyxionNL
2025-09-25 18:54:08 +02:00
parent 4534276fb6
commit 9ad730d811
3 changed files with 270 additions and 310 deletions
@@ -3,41 +3,19 @@
// Copyright (C) 2020 Wilken Gottwalt<wilken.gottwalt@posteo.net>
// Copyright (C) LibreHardwareMonitor and Contributors.
// All Rights Reserved.
// Implemented after the Linuix kernel driver corsair_psu by Wilken Gottwalt and contributers
// Implemented after the Linux kernel driver corsair_psu by Wilken Gottwalt and contributers
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using HidSharp;
namespace LibreHardwareMonitor.Hardware.Psu.Corsair;
internal class SensorIndices
{
private readonly Dictionary<SensorType, int> _lastIndices = new();
public int NextIndex(SensorType type)
{
if (!_lastIndices.ContainsKey(type))
{
_lastIndices.Add(type, 0);
return 0;
}
int res = _lastIndices[type] + 1;
_lastIndices[type] = res;
return res;
}
}
// might need refactoring into two classes if AXi series API differs significantly
internal sealed class CorsairPsu : Hardware
{
private readonly List<CompositeSensor> _compositeSensors = new();
private readonly List<CompositeSensor> _compositeSensors = [];
private readonly HidDevice _device;
private readonly List<PsuSensor> _sensors = new();
private readonly List<PsuSensor> _sensors = [];
public CorsairPsu(HidDevice device, ISettings settings, int index)
: base("Corsair PSU", new Identifier("psu", "corsair", index.ToString()), settings)
@@ -78,9 +56,10 @@ internal sealed class CorsairPsu : Hardware
private void AddSensors(UsbApi.OptionalCommands optionalCommands, UsbApi.Criticals criticals, ISettings settings)
{
SensorIndices indices = new();
int sensorIndex = 0;
_sensors.Add(new PsuSensorWithLimits("VRM",
indices,
sensorIndex++,
SensorType.Temperature,
this,
settings,
@@ -89,7 +68,7 @@ internal sealed class CorsairPsu : Hardware
criticals.TempMax[0]));
_sensors.Add(new PsuSensorWithLimits("Case",
indices,
sensorIndex++,
SensorType.Temperature,
this,
settings,
@@ -97,11 +76,11 @@ internal sealed class CorsairPsu : Hardware
null,
criticals.TempMax[1]));
_sensors.Add(new PsuSensor("Case", indices, SensorType.Fan, this, settings, UsbApi.Command.FAN_RPM));
_sensors.Add(new PsuSensor("Case", sensorIndex++, SensorType.Fan, this, settings, UsbApi.Command.FAN_RPM));
_sensors.Add(new PsuSensor("Input", indices, SensorType.Voltage, this, settings, UsbApi.Command.IN_VOLTS));
_sensors.Add(new PsuSensor("Input", sensorIndex++, SensorType.Voltage, this, settings, UsbApi.Command.IN_VOLTS));
_sensors.Add(new PsuSensorWithLimits("+12V",
indices,
sensorIndex++,
SensorType.Voltage,
this,
settings,
@@ -110,7 +89,7 @@ internal sealed class CorsairPsu : Hardware
criticals.VoltageMax[(byte)Rail._12V]));
_sensors.Add(new PsuSensorWithLimits("+5V",
indices,
sensorIndex++,
SensorType.Voltage,
this,
settings,
@@ -120,7 +99,7 @@ internal sealed class CorsairPsu : Hardware
Rail._5V));
_sensors.Add(new PsuSensorWithLimits("+3.3V",
indices,
sensorIndex++,
SensorType.Voltage,
this,
settings,
@@ -131,11 +110,11 @@ internal sealed class CorsairPsu : Hardware
if (optionalCommands.HasFlag(UsbApi.OptionalCommands.InputCurrent))
{
_sensors.Add(new PsuSensor("Input", indices, SensorType.Current, this, settings, UsbApi.Command.IN_AMPS));
_sensors.Add(new PsuSensor("Input", sensorIndex++, SensorType.Current, this, settings, UsbApi.Command.IN_AMPS));
}
_sensors.Add(new PsuSensorWithLimits("+12V",
indices,
sensorIndex++,
SensorType.Current,
this,
settings,
@@ -144,7 +123,7 @@ internal sealed class CorsairPsu : Hardware
criticals.CurrentMax[(byte)Rail._12V]));
_sensors.Add(new PsuSensorWithLimits("+5V",
indices,
sensorIndex++,
SensorType.Current,
this,
settings,
@@ -154,7 +133,7 @@ internal sealed class CorsairPsu : Hardware
Rail._5V));
_sensors.Add(new PsuSensorWithLimits("+3.3V",
indices,
sensorIndex++,
SensorType.Current,
this,
settings,
@@ -164,16 +143,16 @@ internal sealed class CorsairPsu : Hardware
Rail._3V));
PsuSensor[] powerRails =
{
new("+12V", indices, SensorType.Power, this, settings, UsbApi.Command.RAIL_WATTS),
new("+5V", indices, SensorType.Power, this, settings, UsbApi.Command.RAIL_WATTS, Rail._5V),
new("+3.3V", indices, SensorType.Power, this, settings, UsbApi.Command.RAIL_WATTS, Rail._3V)
};
[
new("+12V", sensorIndex++, SensorType.Power, this, settings, UsbApi.Command.RAIL_WATTS),
new("+5V", sensorIndex++, SensorType.Power, this, settings, UsbApi.Command.RAIL_WATTS, Rail._5V),
new("+3.3V", sensorIndex++, SensorType.Power, this, settings, UsbApi.Command.RAIL_WATTS, Rail._3V)
];
_sensors.AddRange(powerRails);
_sensors.Add(new PsuSensor("Total watts", indices, SensorType.Power, this, settings, UsbApi.Command.TOTAL_WATTS));
_sensors.Add(new PsuSensor("Total watts", sensorIndex++, SensorType.Power, this, settings, UsbApi.Command.TOTAL_WATTS));
_compositeSensors.Add(new CompositeSensor("Total Output",
indices.NextIndex(SensorType.Power),
sensorIndex++,
SensorType.Power,
this,
settings,
@@ -182,8 +161,8 @@ internal sealed class CorsairPsu : Hardware
ActivateSensor(_compositeSensors[_compositeSensors.Count - 1]);
_sensors.Add(new PsuSensor("Uptime", indices, SensorType.TimeSpan, this, settings, UsbApi.Command.UPTIME, Rail._12V, true));
_sensors.Add(new PsuSensor("Total uptime", indices, SensorType.TimeSpan, this, settings, UsbApi.Command.TOTAL_UPTIME, Rail._12V, true));
_sensors.Add(new PsuSensor("Uptime", sensorIndex++, SensorType.TimeSpan, this, settings, UsbApi.Command.UPTIME, Rail._12V, true));
_sensors.Add(new PsuSensor("Total uptime", sensorIndex, SensorType.TimeSpan, this, settings, UsbApi.Command.TOTAL_UPTIME, Rail._12V, true));
}
private class PsuSensor : Sensor
@@ -191,8 +170,8 @@ internal sealed class CorsairPsu : Hardware
private readonly UsbApi.Command _cmd;
private readonly byte _rail;
public PsuSensor(string name, SensorIndices indices, SensorType type, CorsairPsu hardware, ISettings settings, UsbApi.Command cmd, Rail rail = Rail._12V, bool noHistory = false)
: base(name, indices.NextIndex(type), false, type, hardware, null, settings, noHistory)
public PsuSensor(string name, int index, SensorType type, CorsairPsu hardware, ISettings settings, UsbApi.Command cmd, Rail rail = Rail._12V, bool noHistory = false)
: base(name, index, false, type, hardware, null, settings, noHistory)
{
_cmd = cmd;
_rail = (byte)rail;
@@ -206,28 +185,22 @@ internal sealed class CorsairPsu : Hardware
}
}
private class PsuSensorWithLimits : PsuSensor, ICriticalSensorLimits
private class PsuSensorWithLimits
(
string name,
int index,
SensorType type,
CorsairPsu hardware,
ISettings settings,
UsbApi.Command cmd,
float? lowCritical,
float? highCritical,
Rail rail = Rail._12V)
: PsuSensor(name, index, type, hardware, settings, cmd, rail), ICriticalSensorLimits
{
public PsuSensorWithLimits
(
string name,
SensorIndices indices,
SensorType type,
CorsairPsu hardware,
ISettings settings,
UsbApi.Command cmd,
float? lowCritical,
float? highCritical,
Rail rail = Rail._12V)
: base(name, indices, type, hardware, settings, cmd, rail)
{
CriticalLowLimit = lowCritical;
CriticalHighLimit = highCritical;
}
public float? CriticalHighLimit { get; } = highCritical;
public float? CriticalHighLimit { get; }
public float? CriticalLowLimit { get; }
public float? CriticalLowLimit { get; } = lowCritical;
}
private enum Rail : byte
@@ -237,245 +210,3 @@ internal sealed class CorsairPsu : Hardware
_3V = 2
}
}
#region Exception classes
public class CommunicationProtocolError : ApplicationException
{
public CommunicationProtocolError(HidDevice device, string message)
: base($"Error communicating with the PSU controller at {device.DevicePath}: {message}")
{ }
}
#endregion
#region PSU USB communication protocol implementation
internal static class UsbApi
{
/* some values are SMBus LINEAR11 data which need a conversion */
#if false
static int Linear11ToInt(ushort val, int scale)
{
int exp = ((short)val) >> 11;
int mant = (((short)(val & 0x7ff)) << 5) >> 5;
int result = mant * scale;
return (exp >= 0) ? (result << exp) : (result >> -exp);
}
#endif
static float Linear11ToFloat32(ushort val)
{
int exp = (short)val >> 11;
int mant = ((short)(val & 0x7ff) << 5) >> 5;
return mant * (float)Math.Pow(2, exp);
}
static bool SendCommand(HidStream stream, byte length, Command cmd, byte arg, out byte[] replyData)
{
/*
* Corsair protocol for PSUs
*
* message size = 64 bytes (request and response, little endian)
* request:
* [length][command][param0][param1][paramX]...
* reply:
* [echo of length][echo of command][data0][data1][dataX]...
*
* - commands are byte sized opcodes
* - length is the sum of all bytes of the commands/params
* - the micro-controller of most of these PSUs support concatenation in the request and reply,
* but it is better to not rely on this (it is also hard to parse)
* - the driver uses raw events to be accessible from userspace (though this is not really
* supported, it is just there for convenience, may be removed in the future)
* - a reply always start with the length and command in the same order the request used it
* - length of the reply data is specific to the command used
* - some of the commands work on a rail and can be switched to a specific rail (0 = 12v,
* 1 = 5v, 2 = 3.3v)
* - the format of the init command 0xFE is swapped length/command bytes
* - parameter bytes amount and values are specific to the command (rail setting is the only
* for now that uses non-zero values)
* - there are much more commands, especially for configuring the device, but they are not
* supported because a wrong command/length can lockup the micro-controller
* - the driver supports debugfs for values not fitting into the hwmon class
* - not every device class (HXi, RMi or AXi) supports all commands
* - it is a pure sensors reading driver (will not support configuring)
*/
const int cmdBufferSize = 64;
const int replySize = 16;
byte[] cmdBuffer = new byte[cmdBufferSize + 1];
cmdBuffer[0] = 0; // report id
cmdBuffer[1] = length;
cmdBuffer[2] = (byte)cmd;
cmdBuffer[3] = arg;
stream.Write(cmdBuffer);
byte[] reply = stream.Read();
replyData = new byte[replySize];
Array.Copy(reply, 3, replyData, 0, replySize);
return reply[1] == cmdBuffer[1] && reply[2] == cmdBuffer[2];
}
public static void Init(HidStream stream)
{
/*
* PSU_CMD_INIT uses swapped length/command and expects 2 parameter bytes, this command
* actually generates a reply, but we don't need it
*/
SendCommand(stream, (byte)Command.INIT, (Command)3, 0, out _);
}
public struct FirmwareInfo
{
public string Vendor;
public string Product;
}
public static FirmwareInfo FwInfo(HidStream stream)
{
if (!SendCommand(stream, 3, Command.VEND_STR, 0, out byte[] vendorArr))
throw new CommunicationProtocolError(stream.Device, "Can't read vendor string");
if (!SendCommand(stream, 3, Command.PROD_STR, 0, out byte[] productArr))
throw new CommunicationProtocolError(stream.Device, "Can't read product");
string ArrayToString(byte[] ar)
{
return Encoding.ASCII.GetString(ar.TakeWhile(x => x != 0).ToArray());
}
return new FirmwareInfo { Vendor = ArrayToString(vendorArr), Product = ArrayToString(productArr) };
}
static bool Request(HidStream stream, Command cmd, byte rail, out byte[] data)
{
//mutex_lock(&priv->lock) ;
switch (cmd)
{
case Command.RAIL_VOLTS_HCRIT:
case Command.RAIL_VOLTS_LCRIT:
case Command.RAIL_AMPS_HCRIT:
case Command.RAIL_VOLTS:
case Command.RAIL_AMPS:
case Command.RAIL_WATTS:
if (!SendCommand(stream, 2, Command.SELECT_RAIL, rail, out _))
{
data = null;
return false;
}
break;
}
return SendCommand(stream, 3, cmd, 0, out data);
// mutex_unlock(&priv->lock) ;
// return ret;
}
public static float? GetValue(HidStream stream, Command cmd, byte rail)
{
if (!Request(stream, cmd, rail, out byte[] data))
{
return null;
}
/*
* the biggest value here comes from the uptime command and to exceed MAXINT total uptime
* needs to be about 68 years, the rest are u16 values and the biggest value coming out of
* the LINEAR11 conversion are the watts values which are about 1200 for the strongest psu
* supported (HX1200i)
*/
int tmp = BitConverter.ToInt32(data, 0); // ((int)data[3] << 24) + (data[2] << 16) + (data[1] << 8) + data[0];
return cmd switch
{
Command.RAIL_VOLTS_HCRIT or Command.RAIL_VOLTS_LCRIT or Command.RAIL_AMPS_HCRIT or Command.TEMP_HCRIT or Command.IN_VOLTS or Command.IN_AMPS or Command.RAIL_VOLTS or Command.RAIL_AMPS
or Command.TEMP0 or Command.TEMP1 or Command.FAN_RPM or Command.RAIL_WATTS or Command.TOTAL_WATTS => Linear11ToFloat32((ushort)tmp), // Linear11ToInt((ushort)tmp, 1000000);
Command.TOTAL_UPTIME or Command.UPTIME or Command.OCPMODE => tmp,
_ => null
};
}
public struct Criticals
{
public float?[] TempMax;
public float?[] VoltageMin;
public float?[] VoltageMax;
public float?[] CurrentMax;
}
public static Criticals GetCriticals(HidStream stream)
{
Criticals res = new();
const byte tempCount = 2;
res.TempMax = new float?[tempCount];
for (byte rail = 0; rail < tempCount; rail++)
{
res.TempMax[rail] = GetValue(stream, Command.TEMP_HCRIT, rail);
}
const byte railCount = 3; /* 3v + 5v + 12v */
res.VoltageMin = new float?[railCount];
res.VoltageMax = new float?[railCount];
res.CurrentMax = new float?[railCount];
for (byte rail = 0; rail < railCount; rail++)
{
res.VoltageMax[rail] = GetValue(stream, Command.RAIL_VOLTS_HCRIT, rail);
res.VoltageMin[rail] = GetValue(stream, Command.RAIL_VOLTS_LCRIT, rail);
res.CurrentMax[rail] = GetValue(stream, Command.RAIL_AMPS_HCRIT, rail);
}
return res;
}
[Flags]
public enum OptionalCommands
{
None = 0x0,
InputCurrent = 0x1
}
public static OptionalCommands GetOptionalCommands(HidStream stream)
{
OptionalCommands res = OptionalCommands.None;
if (GetValue(stream, Command.IN_AMPS, 0).HasValue)
{
res |= OptionalCommands.InputCurrent;
}
return res;
}
public enum Command : byte
{
SELECT_RAIL = 0x00, /* expects length 2 */
RAIL_VOLTS_HCRIT = 0x40, /* the rest of the commands expect length 3 */
RAIL_VOLTS_LCRIT = 0x44,
RAIL_AMPS_HCRIT = 0x46,
TEMP_HCRIT = 0x4F,
IN_VOLTS = 0x88,
IN_AMPS = 0x89,
RAIL_VOLTS = 0x8B,
RAIL_AMPS = 0x8C,
TEMP0 = 0x8D,
TEMP1 = 0x8E,
FAN_RPM = 0x90,
RAIL_WATTS = 0x96,
VEND_STR = 0x99,
PROD_STR = 0x9A,
TOTAL_UPTIME = 0xD1,
UPTIME = 0xD2,
OCPMODE = 0xD8,
TOTAL_WATTS = 0xEE,
INIT = 0xFE
}
}
#endregion
@@ -0,0 +1,218 @@
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (C) LibreHardwareMonitor and Contributors.
// All Rights Reserved.
using System;
using System.Linq;
using System.Text;
using HidSharp;
namespace LibreHardwareMonitor.Hardware.Psu.Corsair;
internal static class UsbApi
{
public enum Command : byte
{
SELECT_RAIL = 0x00, /* expects length 2 */
RAIL_VOLTS_HCRIT = 0x40, /* the rest of the commands expect length 3 */
RAIL_VOLTS_LCRIT = 0x44,
RAIL_AMPS_HCRIT = 0x46,
TEMP_HCRIT = 0x4F,
IN_VOLTS = 0x88,
IN_AMPS = 0x89,
RAIL_VOLTS = 0x8B,
RAIL_AMPS = 0x8C,
TEMP0 = 0x8D,
TEMP1 = 0x8E,
FAN_RPM = 0x90,
RAIL_WATTS = 0x96,
VEND_STR = 0x99,
PROD_STR = 0x9A,
TOTAL_UPTIME = 0xD1,
UPTIME = 0xD2,
OCPMODE = 0xD8,
TOTAL_WATTS = 0xEE,
INIT = 0xFE
}
[Flags]
public enum OptionalCommands
{
None = 0x0,
InputCurrent = 0x1
}
public static void Init(HidStream stream)
{
/*
* PSU_CMD_INIT uses swapped length/command and expects 2 parameter bytes, this command
* actually generates a reply, but we don't need it
*/
SendCommand(stream, (byte)Command.INIT, (Command)3, 0, out _);
}
public static float? GetValue(HidStream stream, Command cmd, byte rail)
{
if (!Request(stream, cmd, rail, out byte[] data))
return null;
/*
* the biggest value here comes from the uptime command and to exceed MAXINT total uptime
* needs to be about 68 years, the rest are u16 values and the biggest value coming out of
* the LINEAR11 conversion are the watts values which are about 1200 for the strongest psu
* supported (HX1200i)
*/
int tmp = BitConverter.ToInt32(data, 0); // ((int)data[3] << 24) + (data[2] << 16) + (data[1] << 8) + data[0];
return cmd switch
{
Command.RAIL_VOLTS_HCRIT or Command.RAIL_VOLTS_LCRIT or Command.RAIL_AMPS_HCRIT or Command.TEMP_HCRIT or Command.IN_VOLTS or Command.IN_AMPS or Command.RAIL_VOLTS or Command.RAIL_AMPS
or Command.TEMP0 or Command.TEMP1 or Command.FAN_RPM or Command.RAIL_WATTS or Command.TOTAL_WATTS => Linear11ToFloat32((ushort)tmp), // Linear11ToInt((ushort)tmp, 1000000);
Command.TOTAL_UPTIME or Command.UPTIME or Command.OCPMODE => tmp,
_ => null
};
}
public static Criticals GetCriticals(HidStream stream)
{
Criticals res = new();
const byte tempCount = 2;
res.TempMax = new float?[tempCount];
for (byte rail = 0; rail < tempCount; rail++)
res.TempMax[rail] = GetValue(stream, Command.TEMP_HCRIT, rail);
const byte railCount = 3; /* 3v + 5v + 12v */
res.VoltageMin = new float?[railCount];
res.VoltageMax = new float?[railCount];
res.CurrentMax = new float?[railCount];
for (byte rail = 0; rail < railCount; rail++)
{
res.VoltageMax[rail] = GetValue(stream, Command.RAIL_VOLTS_HCRIT, rail);
res.VoltageMin[rail] = GetValue(stream, Command.RAIL_VOLTS_LCRIT, rail);
res.CurrentMax[rail] = GetValue(stream, Command.RAIL_AMPS_HCRIT, rail);
}
return res;
}
public static OptionalCommands GetOptionalCommands(HidStream stream)
{
OptionalCommands res = OptionalCommands.None;
if (GetValue(stream, Command.IN_AMPS, 0).HasValue)
res |= OptionalCommands.InputCurrent;
return res;
}
public static FirmwareInfo FwInfo(HidStream stream)
{
if (!SendCommand(stream, 3, Command.VEND_STR, 0, out byte[] vendorArr))
throw new ProtocolError(stream.Device, "Can't read vendor string");
if (!SendCommand(stream, 3, Command.PROD_STR, 0, out byte[] productArr))
throw new ProtocolError(stream.Device, "Can't read product");
static string ArrayToString(byte[] ar)
{
return Encoding.ASCII.GetString(ar.TakeWhile(x => x != 0).ToArray());
}
return new FirmwareInfo { Vendor = ArrayToString(vendorArr), Product = ArrayToString(productArr) };
}
private static bool SendCommand(HidStream stream, byte length, Command cmd, byte arg, out byte[] replyData)
{
/*
* Corsair protocol for PSUs
*
* message size = 64 bytes (request and response, little endian)
* request:
* [length][command][param0][param1][paramX]...
* reply:
* [echo of length][echo of command][data0][data1][dataX]...
*
* - commands are byte sized opcodes
* - length is the sum of all bytes of the commands/params
* - the micro-controller of most of these PSUs support concatenation in the request and reply,
* but it is better to not rely on this (it is also hard to parse)
* - the driver uses raw events to be accessible from userspace (though this is not really
* supported, it is just there for convenience, may be removed in the future)
* - a reply always start with the length and command in the same order the request used it
* - length of the reply data is specific to the command used
* - some of the commands work on a rail and can be switched to a specific rail (0 = 12v,
* 1 = 5v, 2 = 3.3v)
* - the format of the init command 0xFE is swapped length/command bytes
* - parameter bytes amount and values are specific to the command (rail setting is the only
* for now that uses non-zero values)
* - there are much more commands, especially for configuring the device, but they are not
* supported because a wrong command/length can lockup the micro-controller
* - the driver supports debugfs for values not fitting into the hwmon class
* - not every device class (HXi, RMi or AXi) supports all commands
* - it is a pure sensors reading driver (will not support configuring)
*/
const int cmdBufferSize = 64;
const int replySize = 16;
byte[] cmdBuffer = new byte[cmdBufferSize + 1];
cmdBuffer[0] = 0; // report id
cmdBuffer[1] = length;
cmdBuffer[2] = (byte)cmd;
cmdBuffer[3] = arg;
stream.Write(cmdBuffer);
byte[] reply = stream.Read();
replyData = new byte[replySize];
Array.Copy(reply, 3, replyData, 0, replySize);
return reply[1] == cmdBuffer[1] && reply[2] == cmdBuffer[2];
}
private static bool Request(HidStream stream, Command cmd, byte rail, out byte[] data)
{
switch (cmd)
{
case Command.RAIL_VOLTS_HCRIT:
case Command.RAIL_VOLTS_LCRIT:
case Command.RAIL_AMPS_HCRIT:
case Command.RAIL_VOLTS:
case Command.RAIL_AMPS:
case Command.RAIL_WATTS:
if (!SendCommand(stream, 2, Command.SELECT_RAIL, rail, out _))
{
data = null;
return false;
}
break;
}
return SendCommand(stream, 3, cmd, 0, out data);
}
private static float Linear11ToFloat32(ushort val)
{
int exp = (short)val >> 11;
int mant = (short)(val & 0x7ff) << 5 >> 5;
return mant * (float)Math.Pow(2, exp);
}
public struct FirmwareInfo
{
public string Vendor;
public string Product;
}
public struct Criticals
{
public float?[] TempMax;
public float?[] VoltageMin;
public float?[] VoltageMax;
public float?[] CurrentMax;
}
}
@@ -0,0 +1,11 @@
using System;
using HidSharp;
namespace LibreHardwareMonitor.Hardware.Psu;
/// <summary>
/// Represents an error that occurs during communication with a PSU controller over USB.
/// </summary>
/// <param name="device">The HID device associated with the communication error. Cannot be null.</param>
/// <param name="message">The error message that describes the nature of the communication failure.</param>
public class ProtocolError(HidDevice device, string message) : ApplicationException($"Error communicating with the PSU controller at {device.DevicePath}: {message}");