commander_pro: add support for changing fan modes during init (#474)

Closes: #472.
This commit is contained in:
Marshall Asch 2022-10-16 20:20:44 -04:00 committed by GitHub
parent 90ef98f721
commit c03b445d0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 205 additions and 34 deletions

View File

@ -38,6 +38,12 @@ Corsair Lighting Node Pro
```
Passing `--fan-mode='<fan_num>:<mode>[,...]'` can be used to change the
fan mode from `dc` to `pwm` to `off` if the connected fan type is changed.
The `--fan-mode` option is persistent across restarts and can only be used
when not using the hwmon driver.
## Retrieving the fan speeds, temperatures and voltages
The Lighting Node Pro and Lighting Node Core do not have a status message.

View File

@ -105,6 +105,7 @@ _liquidctl_main() {
--start-led
--maximum-leds
--temperature-sensor
--fan-mode
"
# generate options list and remove any flag that has already been given

View File

@ -163,6 +163,9 @@ The number of LED's the effect should apply to.
.
.SS Other options
.TP
.BI \-\-fan\-mode= channel:mode[,...]
Set the fan modes.
.TP
.B \-\-single\-12v\-ocp
Enable single rail +12V OCP.
.TP
@ -251,6 +254,10 @@ Lighting channels: not yet supported.
.SS Corsair Obsidian 1000D
Cooling channels (only Commander Pro and Obsidian 1000D): \fIsync\fR, \fIfan[1\-6]\fR.
.PP
Where the fan connection types can be set with
.BI \-\-fan\-mode= channel:mode[,...] ,
where the allowed modes are: \fIoff\fR, \fIdc\fR, \fIpwm\fR.
.PP
Lighting channels: (only Lighting Node Core:) \fIled\fR;
(only Commander Pro and Lighting Node Pro:) \fIsync\fR, \fIled[1\-2]\fR.
.TS

View File

@ -12,41 +12,42 @@ Usage:
liquidctl --version
Device selection options (see: list -v):
-m, --match <substring> Filter devices by description substring
-n, --pick <number> Pick among many results for a given filter
--vendor <id> Filter devices by hexadecimal vendor ID
--product <id> Filter devices by hexadecimal product ID
--release <number> Filter devices by hexadecimal release number
--serial <number> Filter devices by serial number
--bus <bus> Filter devices by bus
--address <address> Filter devices by address in bus
--usb-port <port> Filter devices by USB port in bus
-m, --match <substring> Filter devices by description substring
-n, --pick <number> Pick among many results for a given filter
--vendor <id> Filter devices by hexadecimal vendor ID
--product <id> Filter devices by hexadecimal product ID
--release <number> Filter devices by hexadecimal release number
--serial <number> Filter devices by serial number
--bus <bus> Filter devices by bus
--address <address> Filter devices by address in bus
--usb-port <port> Filter devices by USB port in bus
Animation options (devices/modes can support zero or more):
--speed <value> Abstract animation speed (device/mode specific)
--time-per-color <value> Time to wait on each color (seconds)
--time-off <value> Time to wait with the LED turned off (seconds)
--alert-threshold <number> Threshold temperature for a visual alert (°C)
--alert-color <color> Color used by the visual high temperature alert
--direction <string> If the pattern should move forward or backward.
--start-led <number> The first led to start the effect at
--maximum-leds <number> The number of LED's the effect should apply to
--speed <value> Abstract animation speed (device/mode specific)
--time-per-color <value> Time to wait on each color (seconds)
--time-off <value> Time to wait with the LED turned off (seconds)
--alert-threshold <number> Threshold temperature for a visual alert (°C)
--alert-color <color> Color used by the visual high temperature alert
--direction <string> If the pattern should move forward or backward.
--start-led <number> The first led to start the effect at
--maximum-leds <number> The number of LED's the effect should apply to
Other device options:
--single-12v-ocp Enable single rail +12V OCP
--pump-mode <mode> Set the pump mode (certain Corsair coolers)
--temperature-sensor <number> The temperature sensor number for the Commander Pro
--legacy-690lc Use Asetek 690LC in legacy mode (old Krakens)
--non-volatile Store on non-volatile controller memory
--direct-access Directly access the device despite kernel drivers
--unsafe <features> Comma-separated bleeding-edge features to enable
--single-12v-ocp Enable single rail +12V OCP
--pump-mode <mode> Set the pump mode (certain Corsair coolers)
--fan-mode <channel>:<mode>[,...] Set the mode for each fan (certain Corsair devices)
--temperature-sensor <number> The temperature sensor number for the Commander Pro
--legacy-690lc Use Asetek 690LC in legacy mode (old Krakens)
--non-volatile Store on non-volatile controller memory
--direct-access Directly access the device despite kernel drivers
--unsafe <features> Comma-separated bleeding-edge features to enable
Other interface options:
-v, --verbose Output additional information
-g, --debug Show debug information on stderr
--json JSON output (list/initialization/status)
--version Display the version number
--help Show this message
-v, --verbose Output additional information
-g, --debug Show debug information on stderr
--json JSON output (list/initialization/status)
--version Display the version number
--help Show this message
Deprecated:
-d, --device <index> Select device by listing index
@ -83,7 +84,7 @@ from docopt import docopt
from liquidctl import __version__
from liquidctl.driver import *
from liquidctl.error import NotSupportedByDevice, NotSupportedByDriver, UnsafeFeaturesNotEnabled
from liquidctl.util import color_from_str
from liquidctl.util import color_from_str, fan_mode_parser
# conversion from CLI arg to internal option; as options as forwarded to bused
@ -116,6 +117,7 @@ _PARSE_ARG = {
'--legacy-690lc': bool,
'--non-volatile': bool,
'--direct-access': bool,
'--fan-mode': lambda x: fan_mode_parser(x),
'--unsafe': lambda x: x.lower().split(','),
'--verbose': bool,
'--debug': bool,

View File

@ -41,6 +41,7 @@ _CMD_GET_FAN_MODES = 0x20
_CMD_GET_FAN_RPM = 0x21
_CMD_SET_FAN_DUTY = 0x23
_CMD_SET_FAN_PROFILE = 0x25
_CMD_SET_FAN_MODE = 0x28
_CMD_RESET_LED_CHANNEL = 0x37
_CMD_BEGIN_LED_EFFECT = 0x34
@ -83,6 +84,11 @@ _MODES = {
'rainbow2': 0x0a,
}
_FAN_MODES = {
'off': _FAN_MODE_DISCONNECTED,
'dc': _FAN_MODE_DC,
'pwm': _FAN_MODE_PWM,
}
def _prepare_profile(original, critcalTempature):
clamped = ((temp, clamp(duty, 0, _MAX_FAN_RPM)) for temp, duty in original)
@ -157,7 +163,7 @@ class CommanderPro(UsbHidDriver):
self._data = RuntimeStorage(key_prefixes=[ids, loc])
return ret
def _initialize_directly(self, **kwargs):
def _initialize_directly(self, fan_modes: dict, **kwargs):
res = self._send_command(_CMD_GET_FIRMWARE)
fw_version = (res[1], res[2], res[3])
@ -179,6 +185,13 @@ class CommanderPro(UsbHidDriver):
]
if self._fan_count > 0:
for i, value in fan_modes.items():
fan_num = int(i) - 1
if value not in ['dc', 'pwm', 'off']:
raise ValueError(f"invalid fan mode: '{value}'")
self._send_command(_CMD_SET_FAN_MODE, [0x02, fan_num, _FAN_MODES.get(value)])
res = self._send_command(_CMD_GET_FAN_MODES)
fanModes = res[1:self._fan_count+1]
self._data.store('fan_modes', fanModes)
@ -230,7 +243,7 @@ class CommanderPro(UsbHidDriver):
return status
def initialize(self, direct_access=False, **kwargs):
def initialize(self, direct_access=False, fan_modes={}, **kwargs):
"""Initialize the device and the driver.
This method should be called every time the systems boots, resumes from
@ -245,12 +258,15 @@ class CommanderPro(UsbHidDriver):
if self._hwmon and not direct_access:
_LOGGER.info('bound to %s kernel driver, assuming it is already initialized',
self._hwmon.driver)
if fan_modes:
_LOGGER.warning('kernel driver does not support the `fan mode` options, ignoring')
return self._get_static_info_from_hwmon()
else:
if self._hwmon:
_LOGGER.warning('forcing re-initialization despite %s kernel driver',
self._hwmon.driver)
return self._initialize_directly()
return self._initialize_directly(fan_modes)
def _get_status_directly(self):
temp_probes = self._data.load('temp_sensors_connected', default=[0]*self._temp_probs)

View File

@ -8,6 +8,8 @@ import colorsys
import logging
from ast import literal_eval
from enum import Enum, EnumMeta, unique
from typing import Optional
from functools import lru_cache
import crcmod.predefined
@ -404,6 +406,117 @@ def map_direction(direction, forward=None, backward=None):
raise ValueError(f'invalid direction: {direction!r}')
def fan_mode_parser(value: Optional[str], max_fans: Optional[int] = None) -> dict:
"""Convert the --fan-mode=<key>:<value>[,...] options into a {key: value, ....} dictionary.
The default is an empty dictionary.
Unstable.
>>> fan_mode_parser(None, 5)
{}
>>> fan_mode_parser('', 5)
{}
>>> fan_mode_parser('1:dc', 5)
{'1': 'dc'}
>>> fan_mode_parser('2:auto', 5)
{'2': 'auto'}
>>> fan_mode_parser('3:off', 5)
{'3': 'off'}
>>> fan_mode_parser('1:DC', 5)
{'1': 'dc'}
>>> fan_mode_parser('2:AUTO', 5)
{'2': 'auto'}
>>> fan_mode_parser('3:OFF', 5)
{'3': 'off'}
>>> fan_mode_parser('5:OFF', 5)
{'5': 'off'}
>>> fan_mode_parser('5:OFF')
{'5': 'off'}
>>> fan_mode_parser('1:dc,2:auto,3:off,4:pwm', 5)
{'1': 'dc', '2': 'auto', '3': 'off', '4': 'pwm'}
>>> fan_mode_parser('1:dc, 2:auto, 3:off', 5)
{'1': 'dc', '2': 'auto', '3': 'off'}
>>> fan_mode_parser('4:dc, 3:auto, 2:off', 5)
{'2': 'off', '3': 'auto', '4': 'dc'}
>>> fan_mode_parser('1 :dc, 2: auto, 3 : off, 4 : auto ', 5)
{'1': 'dc', '2': 'auto', '3': 'off', '4': 'auto'}
>>> fan_mode_parser('1:dc:dc', 5)
Traceback (most recent call last):
...
ValueError: invalid format, should be '<fan num>:<mode>'
>>> fan_mode_parser('-1:dc', 5)
Traceback (most recent call last):
...
ValueError: invalid fan number: '-1'
>>> fan_mode_parser('0:pwm', 5)
Traceback (most recent call last):
...
ValueError: invalid fan number: '0'
>>> fan_mode_parser('5:dc', 4)
Traceback (most recent call last):
...
ValueError: invalid fan number: '5'
>>> fan_mode_parser('a:dc', 5)
Traceback (most recent call last):
...
ValueError: invalid fan number: 'a'
>>> fan_mode_parser('1:PMW', 5)
Traceback (most recent call last):
...
ValueError: invalid fan mode: 'PMW'
"""
if not value:
return {}
parts = value.split(',')
opts = {}
for p in parts:
p2 = p.split(':')
if len(p2) != 2:
raise ValueError("invalid format, should be '<fan num>:<mode>'")
[key, val] = [i.strip() for i in p2]
try:
key_val = int(key, 10)
except ValueError:
raise ValueError(f"invalid fan number: '{key}'")
if key_val <= 0 or (max_fans is not None and key_val > max_fans):
raise ValueError(f"invalid fan number: '{key}'")
if val.lower() not in ['off', 'auto', 'dc', 'pwm']:
raise ValueError(f"invalid fan mode: '{val}'")
opts.update({key: val.lower()})
return dict(sorted(opts.items(), key=lambda item: item[0]))
@lru_cache(maxsize=None)
def mkCrcFun(crc_name):
"""Efficiently construct a predefined CRC function.

View File

@ -164,6 +164,32 @@ def test_connect_lighting(lightingNodeProDeviceUnconnected):
assert lightingNodeProDeviceUnconnected._data is not None
@pytest.mark.parametrize('exp,fan_modes', [([(3, 0x01)], {'4': 'dc'}), ([(3, 0x00)], {'4': 'off'}), ([(4, 0x02)], {'5': 'pwm'}), ([(0, 0x01),(2, 0x02),(1, 0x01)], {'1': 'dc', '3':'pwm', '2': 'dc'})])
def test_initialize_commander_pro_fan_modes(commanderProDevice, exp, fan_modes, tmp_path):
responses = [
'000009d4000000000000000000000000', # firmware
'00000500000000000000000000000000', # bootloader
'00010100010000000000000000000000', # temp probes
'00010102000000000000000000000000', # fan set (throw away)
'00010102000000000000000000000000', # fan set (throw away)
'00010102000000000000000000000000', # fan set (throw away)
'00010102000000000000000000000000' # fan probes
]
for d in responses:
commanderProDevice.device.preload_read(Report(0, bytes.fromhex(d)))
commanderProDevice.initialize(direct_access=True, fan_modes=fan_modes)
sent = commanderProDevice.device.sent
assert len(sent) == 4+len(exp)
for i in range(len(exp)):
assert sent[3+i].data[0] == 0x28
assert sent[3+i].data[2] == exp[i][0]
assert sent[3+i].data[3] == exp[i][1]
@pytest.mark.parametrize('has_hwmon,direct_access', [(False, False), (True, True), (True, False)])
def test_initialize_commander_pro(commanderProDevice, has_hwmon, direct_access, tmp_path):
if has_hwmon and not direct_access:
@ -234,7 +260,7 @@ def test_initialize_commander_pro(commanderProDevice, has_hwmon, direct_access,
def test_initialize_lighting_node(lightingNodeProDevice):
responses = [
'000009d4000000000000000000000000', # firmware
'00000500000000000000000000000000' # bootloader
'00000500000000000000000000000000' # bootloader
]
for d in responses:
lightingNodeProDevice.device.preload_read(Report(0, bytes.fromhex(d)))