"""liquidctl drivers for Corsair HXi and RMi series power supply units.
Supported devices:
- Corsair HXi (HX750i, HX850i, HX1000i and HX1200i)
- Corsair RMi (RM650i, RM750i, RM850i and RM1000i)
Copyright Jonas Malaco and contributors
Port of corsaiRMi by notaz and realies.
Copyright (c) notaz, 2016
Incorporates or uses as reference work by Sean Nelson.
SPDX-License-Identifier: GPL-3.0-or-later
"""
import logging
from datetime import timedelta
from enum import Enum
from liquidctl.driver.usb import UsbHidDriver
from liquidctl.error import NotSupportedByDevice
from liquidctl.pmbus import CommandCode as CMD
from liquidctl.pmbus import WriteBit, linear_to_float
from liquidctl.util import clamp
_LOGGER = logging.getLogger(__name__)
_REPORT_LENGTH = 64
_SLAVE_ADDRESS = 0x02
_CORSAIR_READ_TOTAL_UPTIME = CMD.MFR_SPECIFIC_D1
_CORSAIR_READ_UPTIME = CMD.MFR_SPECIFIC_D2
_CORSAIR_12V_OCP_MODE = CMD.MFR_SPECIFIC_D8
_CORSAIR_READ_OUTPUT_POWER = CMD.MFR_SPECIFIC_EE
_CORSAIR_FAN_CONTROL_MODE = CMD.MFR_SPECIFIC_F0
_RAIL_12V = 0x0
_RAIL_5V = 0x1
_RAIL_3P3V = 0x2
_RAIL_NAMES = {_RAIL_12V: '+12V', _RAIL_5V: '+5V', _RAIL_3P3V: '+3.3V'}
_MIN_FAN_DUTY = 30
class OCPMode(Enum):
"""Overcurrent protection mode."""
SINGLE_RAIL = 0x1
MULTI_RAIL = 0x2
def __str__(self):
return self.name.capitalize().replace('_', ' ')
class FanControlMode(Enum):
"""Fan control mode."""
HARDWARE = 0x0
SOFTWARE = 0x1
def __str__(self):
return self.name.capitalize()
class CorsairHidPsu(UsbHidDriver):
"""Corsair HXi or RMi series power supply unit."""
# support for hwmon: corsair-psu, Linux 5.11 (5.13 recommended)
# note: quadratic equation coefficients calculated with the following script:
# https://github.com/liquidctl/collected-device-data/blob/master/psu-efficiency/interpolate
_MATCHES = [
(0x1b1c, 0x1c05, 'Corsair HX750i', {
'fpowin115': (0.00013153276902318052, 1.0118732314945875, 9.783796618886313),
'fpowin230': ( 9.268856467314546e-05, 1.0183515407387007, 8.279822175342481),
}),
(0x1b1c, 0x1c06, 'Corsair HX850i', {
'fpowin115': (0.00011552923724840388, 1.0111311876704099, 12.015296651918918),
'fpowin230': ( 8.126644224872423e-05, 1.0176256272095185, 10.290640442373850),
}),
(0x1b1c, 0x1c07, 'Corsair HX1000i', { # CP-9020074-NA
'fpowin115': (9.48609754417109e-05, 1.0170509865269720, 11.619826520447452),
'fpowin230': (9.649987544008507e-05, 1.0018241767296636, 12.759957859756842),
}),
(0x1b1c, 0x1c08, 'Corsair HX1200i', {
'fpowin115': (6.244705156199815e-05, 1.0234738310580973, 15.293509559389241),
'fpowin230': (5.9413179794350966e-05, 1.0023670927127724, 15.886126793547152),
}),
(0x1b1c, 0x1c23, 'Corsair HX1200i ATX 3.1', {
# CP-9020281-NA
# Certification is source for efficiency numbers https://www.cybenetics.com/evaluations/psus/2510/
# 10% 20% 50% 100%
# 115v: .8877 .91774 .92095 .88146
# 230v: .89881 .92956 .93047 .91292
'fpowin115': (9.930197967499293e-05, 1.003634953854399, 13.956713659543981),
'fpowin230': (4.716701557627399e-05, 1.031689131040792, 8.562560345390088),
}),
(0x1b1c, 0x1c27, 'Corsair HX1200i ATX 3.1 #2', {
# CP-9020307-NA
# Certification is source for efficiency numbers https://www.cybenetics.com/evaluations/psus/2733/
# 10% 20% 50% 100%
# 115v: .88875 .91886 .92168 .88731
# 230v: .89954 .93143 .93185 .92003
'fpowin115': (8.701178559061476e-05, 1.0119502460041445, 12.725770701505295),
'fpowin230': (3.4692421780176756e-05, 1.0391630676290817, 7.429785098514605),
}),
(0x1b1c, 0x1c0a, 'Corsair RM650i', {
'fpowin115': (0.00017323493381072683, 1.0047044721686030, 12.376592422281606),
'fpowin230': (0.00012413136310310370, 1.0284317478987164, 9.465259079360674),
}),
(0x1b1c, 0x1c0b, 'Corsair RM750i', {
'fpowin115': (0.00015013694263596336, 1.0047044721686027, 14.280683564171110),
'fpowin230': (0.00010460621468919797, 1.0173089573727216, 11.495900706372142),
}),
(0x1b1c, 0x1c0c, 'Corsair RM850i', {
'fpowin115': (0.00012280002467981107, 1.0159421430340847, 13.555472968718759),
'fpowin230': ( 8.816054254801031e-05, 1.0234738318592156, 10.832902491655597),
}),
(0x1b1c, 0x1c0d, 'Corsair RM1000i', {
'fpowin115': (0.00010018433053123574, 1.0272313660072225, 14.092187353321624),
'fpowin230': ( 8.600634771656125e-05, 1.0289245073649413, 13.701515390258626),
}),
(0x1b1c, 0x1c1e, 'Corsair HX1000i (2022)', { # CP-9020214-NA
# FIXME does not accurately represent known efficiency curve at 2%–10% loads
'fpowin115': (0.00012038623467957958, 0.9899868099948035, 13.125601514017152),
'fpowin230': ( 8.725695209710315e-05, 1.0017598021499974, 9.789546063300154),
}),
(0x1b1c, 0x1c1f, 'Corsair HX1500i', { # CP-9020215-NA
# FIXME does not accurately represent known efficiency curve at 2%–10% loads
'fpowin115': (6.605968230747892e-05, 1.0125991461405333, 17.96728350708451),
'fpowin230': (4.634428233657273e-05, 1.0183515407387007, 16.559644350684962),
}),
]
def __init__(self, *args, fpowin115=None, fpowin230=None, **kwargs):
"""
Args:
fpowin115: coefficients of a quadratic function that maps from output power to input power at 115V
fpowin230: coefficients of a quadratic function that maps from output power to input power at 230V
"""
assert fpowin115 and fpowin230
super().__init__(*args, **kwargs)
self.fpowin115 = fpowin115
self.fpowin230 = fpowin230
def initialize(self, single_12v_ocp=False, direct_access=False, **kwargs):
"""Initialize the device and the driver.
This method should be called every time the systems boots, resumes from
a suspended state, or if the device has just been (re)connected. In
those scenarios, no other method, except `connect()` or `disconnect()`,
should be called until the device and driver has been (re-)initialized.
Returns None or a list of `(property, value, unit)` tuples, similarly
to `get_status()`.
"""
# necessary to receive non-zero status responses from the device:
# replies before calling this function appear to follow the pattern
#
if self._hwmon:
if not direct_access:
_LOGGER.warning('bound to %s kernel driver, OCP and fan modes not changed',
self._hwmon.driver)
return
else:
_LOGGER.warning('forcing re-initialization despite %s kernel driver',
self._hwmon.driver)
self._write([0xfe, 0x03])
_ = self._read()
# don't check current OCP and fan control modes in case we're racing
# with a hwmon driver and the returned values, which aren't available
# through hwmon, are corrupted by the race
mode = OCPMode.SINGLE_RAIL if single_12v_ocp else OCPMode.MULTI_RAIL
_LOGGER.info('setting +12V OCP mode to %s', mode)
self._exec(WriteBit.WRITE, _CORSAIR_12V_OCP_MODE, [mode.value])
_LOGGER.info('resetting fan control to hardware mode')
self._set_fan_control_mode(FanControlMode.HARDWARE)
def _get_status_directly(self):
self.device.clear_enqueued_reports()
ret = self._exec(WriteBit.WRITE, CMD.PAGE, [0])
if ret[1] == 0xfe:
_LOGGER.warning('possibly uninitialized device')
input_voltage = self._get_float(CMD.READ_VIN)
ret = [
('Current uptime', self._get_timedelta(_CORSAIR_READ_UPTIME), ''),
('Total uptime', self._get_timedelta(_CORSAIR_READ_TOTAL_UPTIME), ''),
('VRM temperature', self._get_float(CMD.READ_TEMPERATURE_1), '°C'),
('Case temperature', self._get_float(CMD.READ_TEMPERATURE_2), '°C'),
('Fan control mode', self._get_fan_control_mode(), ''),
('Fan speed', self._get_float(CMD.READ_FAN_SPEED_1), 'rpm'),
('Input voltage', input_voltage, 'V'),
('+12V OCP mode', self._get_12v_ocp_mode(), ''),
]
for rail in [_RAIL_12V, _RAIL_5V, _RAIL_3P3V]:
name = _RAIL_NAMES[rail]
self._exec(WriteBit.WRITE, CMD.PAGE, [rail])
ret.append((f'{name} output voltage', self._get_float(CMD.READ_VOUT), 'V'))
ret.append((f'{name} output current', self._get_float(CMD.READ_IOUT), 'A'))
ret.append((f'{name} output power', self._get_float(CMD.READ_POUT), 'W'))
output_power = self._get_float(_CORSAIR_READ_OUTPUT_POWER)
input_power = round(self._input_power_at(input_voltage, output_power), 0)
efficiency = round(output_power / input_power * 100, 0)
ret.append(('Total power output', output_power, 'W'))
ret.append(('Estimated input power', input_power, 'W'))
ret.append(('Estimated efficiency', efficiency, '%'))
self._exec(WriteBit.WRITE, CMD.PAGE, [0])
return ret
def _get_status_from_hwmon(self):
# can't report some values (current and total uptime are only available
# on debugfs, and fan and ocp modes are not available at all); still,
# with this particular device, it is better to ignore them than to race
# with a kernel driver
_LOGGER.warning('some attributes cannot be read from %s kernel driver', self._hwmon.driver)
input_voltage = self._hwmon.read_int('in0_input') * 1e-3
ret = [
('VRM temperature', self._hwmon.read_int('temp1_input') * 1e-3, '°C'),
('Case temperature', self._hwmon.read_int('temp2_input') * 1e-3, '°C'),
('Fan speed', self._hwmon.read_int('fan1_input'), 'rpm'),
('Input voltage', input_voltage, 'V'),
]
for n, rail in zip(range(2, 5), [_RAIL_12V, _RAIL_5V, _RAIL_3P3V]):
i = n - 1
name = _RAIL_NAMES[rail]
ret.append((f'{name} output voltage', self._hwmon.read_int(f'in{i}_input') * 1e-3, 'V'))
ret.append((f'{name} output current', self._hwmon.read_int(f'curr{n}_input') * 1e-3, 'A'))
ret.append((f'{name} output power', self._hwmon.read_int(f'power{n}_input') * 1e-6, 'W'))
output_power = self._hwmon.read_int('power1_input') * 1e-6
input_power = round(self._input_power_at(input_voltage, output_power), 0)
efficiency = round(output_power / input_power * 100, 0)
ret.append(('Total power output', output_power, 'W'))
ret.append(('Estimated input power', input_power, 'W'))
ret.append(('Estimated efficiency', efficiency, '%'))
return ret
def get_status(self, direct_access=False, **kwargs):
"""Get a status report.
Returns a list of `(property, value, unit)` tuples.
"""
if self._hwmon and not direct_access:
_LOGGER.info('bound to %s kernel driver, reading status from hwmon', self._hwmon.driver)
return self._get_status_from_hwmon()
if self._hwmon:
_LOGGER.warning('directly reading the status despite %s kernel driver',
self._hwmon.driver)
return self._get_status_directly()
def set_fixed_speed(self, channel, duty, **kwargs):
"""Set channel to a fixed speed duty."""
duty = clamp(duty, _MIN_FAN_DUTY, 100)
_LOGGER.info('ensuring fan control is in software mode')
self._set_fan_control_mode(FanControlMode.SOFTWARE)
_LOGGER.info('setting fan PWM duty to %d%%', duty)
self._exec(WriteBit.WRITE, CMD.FAN_COMMAND_1, [duty])
def set_color(self, channel, mode, colors, **kwargs):
"""Not supported by this device."""
raise NotSupportedByDevice()
def set_speed_profile(self, channel, profile, **kwargs):
"""Not supported by this device."""
raise NotSupportedByDevice()
def _input_power_at(self, input_voltage, output_power):
def quadratic(params, x):
a, b, c = params
return a * x**2 + b * x + c
for_in115v = quadratic(self.fpowin115, output_power)
for_in230v = quadratic(self.fpowin230, output_power)
_LOGGER.debug('input power estimates: %.3f W @ 115 V; %.3f W @ 230 V', for_in115v, for_in230v)
# linearly interpolate for input_voltage
return for_in115v + (for_in230v - for_in115v) * (input_voltage - 115) / 115
def _write(self, data):
assert len(data) <= _REPORT_LENGTH
packet = bytearray(1 + _REPORT_LENGTH)
packet[1: 1 + len(data)] = data # device doesn't use numbered reports
self.device.write(packet)
def _read(self):
return self.device.read(_REPORT_LENGTH)
def _exec(self, writebit, command, data=None):
out = [_SLAVE_ADDRESS | WriteBit(writebit), CMD(command)] + (data or [])
self._write(out)
ret = self._read()
assert ret[0:2] == out[0:2], f'invalid response (possible conflict with another program)'
return ret
def _get_12v_ocp_mode(self):
"""Get +12V single/multi-rail OCP mode."""
return OCPMode(self._exec(WriteBit.READ, _CORSAIR_12V_OCP_MODE)[2])
def _get_fan_control_mode(self):
"""Get hardware/software fan control mode."""
return FanControlMode(self._exec(WriteBit.READ, _CORSAIR_FAN_CONTROL_MODE)[2])
def _set_fan_control_mode(self, mode):
"""Set hardware/software fan control mode."""
return self._exec(WriteBit.WRITE, _CORSAIR_FAN_CONTROL_MODE, [mode.value])
def _get_float(self, command):
"""Get float value with `command`."""
return linear_to_float(self._exec(WriteBit.READ, command)[2:])
def _get_timedelta(self, command):
"""Get timedelta with `command`."""
secs = int.from_bytes(self._exec(WriteBit.READ, command)[2:6], byteorder='little')
return timedelta(seconds=secs)
def set_screen(self, channel, mode, value, **kwargs):
"""Not supported by this device."""
raise NotSupportedByDevice()
# deprecated aliases
CorsairHidPsuDriver = CorsairHidPsu