示例#1
0
class HydroPlatinum(UsbHidDriver):
    """Corsair Hydro Platinum or Pro XT liquid cooler."""

    SUPPORTED_DEVICES = [
        (0x1b1c, 0x0c18, None, 'Corsair Hydro H100i Platinum (experimental)', {
            'fan_count': 2,
            'fan_leds': 4
        }),
        (0x1b1c, 0x0c19, None,
         'Corsair Hydro H100i Platinum SE (experimental)', {
             'fan_count': 2,
             'fan_leds': 16
         }),
        (0x1b1c, 0x0c17, None, 'Corsair Hydro H115i Platinum (experimental)', {
            'fan_count': 2,
            'fan_leds': 4
        }),
        (0x1b1c, 0x0c20, None, 'Corsair Hydro H100i Pro XT (experimental)', {
            'fan_count': 2,
            'fan_leds': 0
        }),
        (0x1b1c, 0x0c21, None, 'Corsair Hydro H115i Pro XT (experimental)', {
            'fan_count': 2,
            'fan_leds': 0
        }),
        (0x1b1c, 0x0c22, None, 'Corsair Hydro H150i Pro XT (experimental)', {
            'fan_count': 3,
            'fan_leds': 0
        }),
    ]

    @classmethod
    def probe(cls,
              handle,
              vendor=None,
              product=None,
              release=None,
              serial=None,
              match=None,
              **kwargs):
        """Probe `handle` and yield corresponding driver instances."""

        # this is modified from BaseUsbDriver.probe to match regardless of
        # presence of "Hydro", for backwards compatibility with 1.5.0 and
        # previous versions

        for vid, pid, _, description, devargs in cls.SUPPORTED_DEVICES:
            if (vendor and vendor != vid) or handle.vendor_id != vid:
                continue
            if (product and product != pid) or handle.product_id != pid:
                continue
            if release and handle.release_number != release:
                continue
            if serial and handle.serial_number != serial:
                continue
            if match:
                match = match.lower()
                descr = description.lower()
                if not (match in descr
                        or match in descr.replace('hydro ', '')):
                    continue
            consargs = devargs.copy()
            consargs.update(kwargs)
            dev = cls(handle, description, **consargs)
            _LOGGER.debug('instanced driver for %s', description)
            yield dev

    def __init__(self, device, description, fan_count, fan_leds, **kwargs):
        super().__init__(device, description, **kwargs)
        self._led_count = 16 + fan_count * fan_leds
        self._fan_names = [f'fan{i + 1}' for i in range(fan_count)]
        self._mincolors = {
            ('led', 'super-fixed'): 1,
            ('led', 'fixed'): 1,
            ('led', 'off'): 0,
        }
        self._maxcolors = {
            ('led', 'super-fixed'): self._led_count,
            ('led', 'fixed'): 1,
            ('led', 'off'): 0,
        }

        # the following fields are only initialized in connect()
        self._data = None
        self._sequence = None

    def connect(self, runtime_storage=None, **kwargs):
        """Connect to the device."""
        ret = super().connect(**kwargs)

        if runtime_storage:
            self._data = runtime_storage
        else:
            ids = f'vid{self.vendor_id:04x}_pid{self.product_id:04x}'
            # must use the HID path because there is no serial number; however,
            # these can be quite long on Windows and macOS, so only take the
            # numbers, since they are likely the only parts that vary between two
            # devices of the same model
            loc = 'loc' + '_'.join(re.findall(r'\d+', self.address))
            self._data = RuntimeStorage(key_prefixes=[ids, loc])

        self._sequence = _sequence(self._data)
        return ret

    def initialize(self, pump_mode='balanced', **kwargs):
        """Initialize the device and set the pump mode.

        The device should be initialized every time it is powered on, including when
        the system resumes from suspending to memory.

        Valid values for `pump_mode` are 'quiet', 'balanced' and 'extreme'.
        Unconfigured fan channels may default to 100% duty.  Subsequent calls
        should leave the fan speeds unaffected.

        Returns a list of `(property, value, unit)` tuples.
        """

        # set the flag so the LED command will need to be set again
        self._data.store('leds_enabled', 0)

        self._data.store('pump_mode', _PumpMode[pump_mode].value)
        res = self._send_set_cooling()
        fw_version = (res[2] >> 4, res[2] & 0xf, res[3])
        if fw_version < (1, 1, 0):
            # see: #201 ("Fan settings affects Fan 1 only and disables fan2")
            _LOGGER.warning(
                'outdated and possibly unsupported firmware version')
        return [('Firmware version', '{}.{}.{}'.format(*fw_version), '')]

    def get_status(self, **kwargs):
        """Get a status report.

        Returns a list of `(property, value, unit)` tuples.
        """

        res = self._send_command(_FEATURE_COOLING, _CMD_GET_STATUS)

        info = [
            ('Liquid temperature', res[8] + res[7] / 255, '°C'),
        ]

        channels = [('Fan 1', 14), ('Fan 2', 21),
                    ('Fan 3', 42)][:len(self._fan_names)]
        channels.append(('Pump', 28))

        for name, base in channels:
            info.append((f'{name} speed', u16le_from(res,
                                                     offset=base + 1), 'rpm'))
            info.append((f'{name} duty', round(res[base] / 255 * 100), '%'))

        return info

    def set_fixed_speed(self, channel, duty, **kwargs):
        """Set fan or fans to a fixed speed duty.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.
        """

        for hw_channel in self._get_hw_fan_channels(channel):
            self._data.store(f'{hw_channel}_mode', _FanMode.FIXED_DUTY.value)
            self._data.store(f'{hw_channel}_duty', duty)
        self._send_set_cooling()

    def set_speed_profile(self, channel, profile, **kwargs):
        """Set fan or fans to follow a speed duty profile.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Up to seven (temperature, duty) pairs can be supplied in `profile`,
        with temperatures in Celsius and duty values in percentage.  The last
        point should set the fan to 100% duty cycle, or be omitted; in the
        latter case the fan will be set to max out at 60°C.
        """

        profile = list(profile)
        for hw_channel in self._get_hw_fan_channels(channel):
            self._data.store(f'{hw_channel}_mode',
                             _FanMode.CUSTOM_PROFILE.value)
            self._data.store(f'{hw_channel}_profile', profile)
        self._send_set_cooling()

    def set_color(self, channel, mode, colors, **kwargs):
        """Set the color of each LED.

        In reality the device does not have the concept of different channels
        or modes, but this driver provides a few for convenience.  Animations
        still require successive calls to this API.

        The 'led' channel can be used to address individual LEDs, and supports
        the 'super-fixed', 'fixed' and 'off' modes.

        In 'super-fixed' mode, each color in `colors` is applied to one
        individual LED, successively.  LEDs for which no color has been
        specified default to off/solid black.  This is closest to how the
        device works.

        In 'fixed' mode, all LEDs are set to the first color taken from
        `colors`.  The `off` mode is equivalent to calling this function with
        'fixed' and a single solid black color in `colors`.

        The `colors` argument should be an iterable of one or more `[red, blue,
        green]` triples, where each red/blue/green component is a value in the
        range 0–255.

        The table bellow summarizes the available channels, modes, and their
        associated maximum number of colors for each device family.

        | Channel  | Mode        | LEDs         | Platinum | Pro XT | Platinum SE |
        | -------- | ----------- | ------------ | -------- | ------ | ----------- |
        | led      | off         | synchronized |        0 |      0 |           0 |
        | led      | fixed       | synchronized |        1 |      1 |           1 |
        | led      | super-fixed | independent  |       24 |     16 |          48 |
        """

        colors = list(colors)
        self._check_color_args(channel, mode, colors)
        if mode == 'off':
            expanded = []
        elif (channel, mode) == ('led', 'super-fixed'):
            expanded = colors[:self._led_count]
        elif (channel, mode) == ('led', 'fixed'):
            expanded = list(
                itertools.chain(*([color] * self._led_count
                                  for color in colors[:1])))
        else:
            assert False, 'assumed unreacheable'

        if self._data.load('leds_enabled', of_type=int, default=0) == 0:
            # These hex strings are currently magic values that work but Im not quite sure why.
            d1 = bytes.fromhex(
                "0101ffffffffffffffffffffffffff7f7f7f7fff00ffffffff00ffffffff00ffffffff00ffffffff00ffffffff00ffffffffffffffffffffffffffffff"
            )
            d2 = bytes.fromhex(
                "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2021222324252627ffffffffffffffffffffffffffffffffffffffffff"
            )
            d3 = bytes.fromhex(
                "28292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4fffffffffffffffffffffffffffffffffffffffffff"
            )

            # Send the magic messages to enable setting the LEDs to statuC values
            self._send_command(None, 0b001, data=d1)
            self._send_command(None, 0b010, data=d2)
            self._send_command(None, 0b011, data=d3)
            self._data.store('leds_enabled', 1)

        data1 = bytes(
            itertools.chain(*((b, g, r) for r, g, b in expanded[0:20])))
        data2 = bytes(
            itertools.chain(*((b, g, r) for r, g, b in expanded[20:40])))
        data3 = bytes(
            itertools.chain(*((b, g, r) for r, g, b in expanded[40:])))

        self._send_command(_FEATURE_LIGHTING, _CMD_SET_LIGHTING1, data=data1)

        if self._led_count > 20:
            self._send_command(_FEATURE_LIGHTING,
                               _CMD_SET_LIGHTING2,
                               data=data2)

        if self._led_count > 40:
            self._send_command(_FEATURE_LIGHTING,
                               _CMD_SET_LIGHTING3,
                               data=data3)

    def _check_color_args(self, channel, mode, colors):
        try:
            mincolors = self._mincolors[(channel, mode)]
            maxcolors = self._maxcolors[(channel, mode)]
        except KeyError:
            raise ValueError(
                f'unsupported (channel, mode) pair, '
                f'should be one of: {_quoted(*self._mincolors)}') from None
        if len(colors) < mincolors:
            raise ValueError(
                f'at least {mincolors} required for {_quoted(channel, mode)}')
        if len(colors) > maxcolors:
            _LOGGER.warning('too many colors, dropping to %d', maxcolors)
            return maxcolors
        return len(colors)

    def _get_hw_fan_channels(self, channel):
        if channel == 'fan':
            return self._fan_names
        if channel in self._fan_names:
            return [channel]
        raise ValueError(
            f'unknown channel, should be one of: {_quoted("fan", *self._fan_names)}'
        )

    def _send_command(self, feature, command, data=None):
        # self.device.write expects buf[0] to be the report number or 0 if not used
        buf = bytearray(_REPORT_LENGTH + 1)
        buf[1] = _WRITE_PREFIX
        buf[2] = next(self._sequence) << 3
        if feature is not None:
            buf[2] |= feature
            buf[3] = command
            start_at = 4
        else:
            buf[2] |= command
            start_at = 3
        if data:
            buf[start_at:start_at + len(data)] = data
        buf[-1] = compute_pec(buf[2:-1])
        self.device.clear_enqueued_reports()
        self.device.write(buf)
        buf = bytes(self.device.read(_REPORT_LENGTH))
        if compute_pec(buf[1:]):
            _LOGGER.warning('response checksum does not match data')
        return buf

    def _generate_cooling_payload(self, fan_names):

        data = bytearray(_SET_COOLING_DATA_LENGTH)
        data[0:len(_SET_COOLING_DATA_PREFIX)] = _SET_COOLING_DATA_PREFIX
        data[_PROFILE_LENGTH_OFFSET] = _PROFILE_LENGTH

        for fan, (imode, iduty, iprofile) in zip(fan_names, _FAN_OFFSETS):
            mode = _FanMode(self._data.load(f'{fan}_mode', of_type=int))
            if mode is _FanMode.FIXED_DUTY:
                stored = self._data.load(f'{fan}_duty',
                                         of_type=int,
                                         default=100)
                duty = clamp(stored, 0, 100)
                data[iduty] = fraction_of_byte(percentage=duty)
                _LOGGER.info('setting %s to %d%% duty cycle', fan, duty)
            elif mode is _FanMode.CUSTOM_PROFILE:
                stored = self._data.load(f'{fan}_profile',
                                         of_type=list,
                                         default=[])
                profile = _prepare_profile(
                    stored)  # ensures correct len(profile)
                pairs = ((temp, fraction_of_byte(percentage=duty))
                         for temp, duty in profile)
                data[iprofile:iprofile +
                     _PROFILE_LENGTH * 2] = itertools.chain(*pairs)
                _LOGGER.info('setting %s to follow profile %r', fan, profile)
            else:
                raise ValueError(f'unsupported fan {mode}')
            data[imode] = mode.value

        return data

    def _send_set_cooling(self):
        data = self._generate_cooling_payload(self._fan_names[0:2])
        data2 = self._generate_cooling_payload(self._fan_names[2:])

        pump_mode = _PumpMode(self._data.load('pump_mode', of_type=int))
        data[_PUMP_MODE_OFFSET] = pump_mode.value
        _LOGGER.info('setting pump mode to %s', pump_mode.name.lower())
        data2[_PUMP_MODE_OFFSET] = 0xff

        if len(self._fan_names) == 3:
            self._send_command(_FEATURE_COOLING2, _CMD_SET_COOLING, data=data2)

        return self._send_command(_FEATURE_COOLING,
                                  _CMD_SET_COOLING,
                                  data=data)
示例#2
0
class CommanderPro(UsbHidDriver):
    """Corsair Commander Pro LED and fan hub"""

    SUPPORTED_DEVICES = [
        (0x1b1c, 0x0c10, None, 'Corsair Commander Pro (experimental)', {
            'fan_count': 6,
            'temp_probs': 4,
            'led_channels': 2
        }),
        (0x1b1c, 0x0c0b, None, 'Corsair Lighting Node Pro (experimental)', {
            'fan_count': 0,
            'temp_probs': 0,
            'led_channels': 2
        }),
        (0x1b1c, 0x0c1a, None, 'Corsair Lighting Node Core (experimental)', {
            'fan_count': 0,
            'temp_probs': 0,
            'led_channels': 1
        }),
    ]

    def __init__(self, device, description, fan_count, temp_probs,
                 led_channels, **kwargs):
        super().__init__(device, description, **kwargs)

        # the following fields are only initialized in connect()
        self._data = None
        self._fan_names = [f'fan{i+1}' for i in range(fan_count)]
        if led_channels == 1:
            self._led_names = ['led']
        else:
            self._led_names = [f'led{i+1}' for i in range(led_channels)]
        self._temp_probs = temp_probs
        self._fan_count = fan_count

    def connect(self, runtime_storage=None, **kwargs):
        """Connect to the device."""
        ret = super().connect(**kwargs)
        if runtime_storage:
            self._data = runtime_storage
        else:
            ids = f'vid{self.vendor_id:04x}_pid{self.product_id:04x}'
            # must use the HID path because there is no serial number; however,
            # these can be quite long on Windows and macOS, so only take the
            # numbers, since they are likely the only parts that vary between two
            # devices of the same model
            loc = 'loc' + '_'.join(re.findall(r'\d+', self.address))
            self._data = RuntimeStorage(key_prefixes=[ids, loc])
        return ret

    def initialize(self, **kwargs):
        """Initialize the device and get the fan modes.

        The device should be initialized every time it is powered on, including when
        the system resumes from suspending to memory.

        Returns a list of `(property, value, unit)` tuples.
        """

        res = self._send_command(_CMD_GET_FIRMWARE)
        fw_version = (res[1], res[2], res[3])

        res = self._send_command(_CMD_GET_BOOTLOADER)
        bootloader_version = (
            res[1], res[2])  # is it possible for there to be a third value?

        status = [
            ('Firmware version', '{}.{}.{}'.format(*fw_version), ''),
            ('Bootloader version', '{}.{}'.format(*bootloader_version), ''),
        ]

        if self._temp_probs > 0:
            res = self._send_command(_CMD_GET_TEMP_CONFIG)
            temp_connected = res[1:5]
            self._data.store('temp_sensors_connected', temp_connected)
            status += [(f'Temperature probe {i + 1}', bool(temp_connected[i]),
                        '') for i in range(4)]

        if self._fan_count > 0:
            # get the information about how the fans are connected, probably want to save this for later
            res = self._send_command(_CMD_GET_FAN_MODES)
            fanModes = res[1:self._fan_count + 1]
            self._data.store('fan_modes', fanModes)
            status += [(f'Fan {i + 1} control mode',
                        _fan_mode_desc(fanModes[i]), '') for i in range(6)]

        return status

    def get_status(self, **kwargs):
        """Get a status report.

        Returns a list of `(property, value, unit)` tuples.
        """

        if self.device.product_id != 0x0c10:
            _LOGGER.debug('only the Commander Pro supports this')
            return []

        temp_probes = self._data.load('temp_sensors_connected',
                                      default=[0] * self._temp_probs)
        fan_modes = self._data.load('fan_modes', default=[0] * self._fan_count)

        status = []

        # get the temperature sensor values
        for i, probe_enabled in enumerate(temp_probes):
            if probe_enabled:
                temp = self._get_temp(i)
                status.append((f'Temperature {i + 1}', temp, '°C'))

        # get fan RPMs of connected fans
        for i, fan_mode in enumerate(fan_modes):
            if fan_mode == _FAN_MODE_DC or fan_mode == _FAN_MODE_PWM:
                speed = self._get_fan_rpm(i)
                status.append((f'Fan {i + 1} speed', speed, 'rpm'))

        # get the real power supply voltages
        for i, rail in enumerate(["+12V", "+5V", "+3.3V"]):
            raw = self._send_command(_CMD_GET_VOLTS, [i])
            voltage = u16be_from(raw, offset=1) / 1000
            status.append((f'{rail} rail', voltage, 'V'))

        return status

    def _get_temp(self, sensor_num):
        """This will get the temperature in degrees celsius for the specified temp sensor.

        sensor number MUST be in range of 0-3
        """

        if self._temp_probs == 0:
            raise ValueError('this device does not have a temperature sensor')

        if sensor_num < 0 or sensor_num > 3:
            raise ValueError(
                f'sensor_num {sensor_num} invalid, must be between 0 and 3')

        res = self._send_command(_CMD_GET_TEMP, [sensor_num])
        temp = u16be_from(res, offset=1) / 100

        return temp

    def _get_fan_rpm(self, fan_num):
        """This will get the rpm value of the fan.

        fan number MUST be in range of 0-5
        """

        if self._fan_count == 0:
            raise ValueError('this device does not have any fans')

        if fan_num < 0 or fan_num > 5:
            raise ValueError(
                f'fan_num {fan_num} invalid, must be between 0 and 5')

        res = self._send_command(_CMD_GET_FAN_RPM, [fan_num])
        speed = u16be_from(res, offset=1)

        return speed

    def _get_hw_fan_channels(self, channel):
        """This will get a list of all the fan channels that the command should be sent to
        It will look up the name of the fan channel given and return a list of the real fan number
        """
        if channel == 'sync':
            return list(range(len(self._fan_names)))
        elif channel in self._fan_names:
            return [self._fan_names.index(channel)]
        elif len(self._fan_names) > 1:
            raise ValueError(
                f'unknown channel, should be one of: {_quoted("sync", *self._fan_names)}'
            )

    def _get_hw_led_channels(self, channel):
        """This will get a list of all the led channels that the command should be sent to
        It will look up the name of the led channel given and return a list of the real led device number
        """
        if channel == 'sync':
            return list(range(len(self._led_names)))
        elif channel in self._led_names:
            return [self._led_names.index(channel)]
        elif len(self._led_names) > 1:
            raise ValueError(
                f'unknown channel, should be one of: {_quoted("sync", *self._led_names)}'
            )

    def set_fixed_speed(self, channel, duty, **kwargs):
        """Set fan or fans to a fixed speed duty.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Different commands for sending fixed percent (0x23) and fixed rpm (0x24)
        Probably want to use fixed percent for this untill the rpm flag is enabled.
        Can only send one fan command at a time, if fan mode is unset will need to send 6?
        messages (or 1 per enabled fan)
        """

        if self._fan_count == 0:
            raise NotSupportedByDevice()

        duty = clamp(duty, 0, 100)
        fan_channels = self._get_hw_fan_channels(channel)
        fan_modes = self._data.load('fan_modes', default=[0] * self._fan_count)

        for fan in fan_channels:
            mode = fan_modes[fan]
            if mode == _FAN_MODE_DC or mode == _FAN_MODE_PWM:
                self._send_command(_CMD_SET_FAN_DUTY, [fan, duty])

    def set_speed_profile(self,
                          channel,
                          profile,
                          temperature_sensor=1,
                          **kwargs):
        """Set fan or fans to follow a speed duty profile.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Up to six (temperature, duty) pairs can be supplied in `profile`,
        with temperatures in Celsius and duty values in percentage.  The last
        point should set the fan to 100% duty cycle, or be omitted; in the
        latter case the fan will be set to max out at 60°C.
        """

        # send fan num, temp sensor, check to make sure it is actually enabled, and do not let the user send external sensor
        # 6 2-byte big endian temps (celsius * 100), then 6 2-byte big endian rpms
        # need to figure out how to find out what the max rpm is for the given fan

        if self._fan_count == 0:
            raise NotSupportedByDevice()

        profile = list(profile)

        criticalTemp = _CRITICAL_TEMPERATURE_HIGH if check_unsafe(
            'high_temperature', **kwargs) else _CRITICAL_TEMPERATURE
        profile = _prepare_profile(profile, criticalTemp)

        # fan_type = kwargs['fan_type'] # need to make sure this is set
        temp_sensor = clamp(temperature_sensor, 1, self._temp_probs)

        sensors = self._data.load('temp_sensors_connected',
                                  default=[0] * self._temp_probs)

        if sensors[temp_sensor - 1] != 1:
            raise ValueError(
                'the specified temperature sensor is not connected')

        buf = bytearray(26)
        buf[1] = temp_sensor - 1  # 0  # use temp sensor 1

        for i, entry in enumerate(profile):
            temp = entry[0] * 100
            rpm = entry[1]

            # convert both values to 2 byte big endian values
            buf[2 + i * 2] = temp.to_bytes(2, byteorder='big')[0]
            buf[3 + i * 2] = temp.to_bytes(2, byteorder='big')[1]
            buf[14 + i * 2] = rpm.to_bytes(2, byteorder='big')[0]
            buf[15 + i * 2] = rpm.to_bytes(2, byteorder='big')[1]

        fan_channels = self._get_hw_fan_channels(channel)
        fan_modes = self._data.load('fan_modes', default=[0] * self._fan_count)

        for fan in fan_channels:
            mode = fan_modes[fan]
            if mode == _FAN_MODE_DC or mode == _FAN_MODE_PWM:
                buf[0] = fan
                self._send_command(_CMD_SET_FAN_PROFILE, buf)

    def set_color(self,
                  channel,
                  mode,
                  colors,
                  direction='forward',
                  speed='medium',
                  start_led=1,
                  maximum_leds=1,
                  **kwargs):
        """Set the color of each LED.

        The table bellow summarizes the available channels, modes, and their
        associated maximum number of colors for each device family.

        | Channel  | Mode        | Num colors |
        | -------- | ----------- | ---------- |
        | led      | off         |          0 |
        | led      | fixed       |          1 |
        | led      | color_shift |          2 |
        | led      | color_pulse |          2 |
        | led      | color_wave  |          2 |
        | led      | visor       |          2 |
        | led      | blink       |          2 |
        | led      | marquee     |          1 |
        | led      | sequential  |          1 |
        | led      | rainbow     |          0 |
        | led      | rainbow2    |          0 |
        """

        # a special mode to clear the current led settings.
        # this is usefull if the the user wants to use a led mode for multiple devices
        if mode == 'clear':
            self._data.store('saved_effects', None)
            return

        colors = list(colors)
        expanded = colors[:3]
        c = itertools.chain(*((r, g, b) for r, g, b in expanded))
        colors = list(c)

        direction = map_direction(direction, _LED_DIRECTION_FORWARD,
                                  _LED_DIRECTION_BACKWARD)
        speed = _LED_SPEED_SLOW if speed == 'slow' else _LED_SPEED_FAST if speed == 'fast' else _LED_SPEED_MEDIUM
        start_led = clamp(start_led, 1, 204) - 1
        num_leds = clamp(maximum_leds, 1, 204 - start_led - 1)
        random_colors = 0x00 if mode == 'off' or len(colors) != 0 else 0x01
        mode_val = _MODES.get(mode, -1)

        if mode_val == -1:
            raise ValueError(f'mode "{mode}" is not valid')

        # FIXME clears on 'off', while the docs only mention this behavior for 'clear'
        saved_effects = [] if mode == 'off' else self._data.load(
            'saved_effects', default=[])

        for led_channel in self._get_hw_led_channels(channel):

            lighting_effect = {
                'channel': led_channel,
                'start_led': start_led,
                'num_leds': num_leds,
                'mode': mode_val,
                'speed': speed,
                'direction': direction,
                'random_colors': random_colors,
                'colors': colors
            }

            saved_effects += [lighting_effect]

            # check to make sure that too many LED effects are not being sent.
            # the max seems to be 8 as found here https://github.com/liquidctl/liquidctl/issues/154#issuecomment-762372583
            if len(saved_effects) > 8:
                _LOGGER.warning(
                    f'too many lighting effects. Run `liquidctl set {channel} color clear` to reset the effect'
                )
                return

            # start sending the led commands
            self._send_command(_CMD_RESET_LED_CHANNEL, [led_channel])
            self._send_command(_CMD_BEGIN_LED_EFFECT, [led_channel])
            self._send_command(_CMD_SET_LED_CHANNEL_STATE, [led_channel, 0x01])

        # FIXME clears on 'off', while the docs only mention this behavior for 'clear'
        self._data.store('saved_effects',
                         None if mode == 'off' else saved_effects)

        for effect in saved_effects:
            config = [
                effect.get('channel'),
                effect.get('start_led'),
                effect.get('num_leds'),
                effect.get('mode'),
                effect.get('speed'),
                effect.get('direction'),
                effect.get('random_colors'), 0xff
            ] + effect.get('colors')
            self._send_command(_CMD_LED_EFFECT, config)

        self._send_command(_CMD_LED_COMMIT, [0xff])

    def _send_command(self, command, data=None):
        # self.device.write expects buf[0] to be the report number or 0 if not used
        buf = bytearray(_REPORT_LENGTH + 1)
        buf[1] = command
        start_at = 2

        if data:
            data = data[:_REPORT_LENGTH - 1]
            buf[start_at:start_at + len(data)] = data

        self.device.clear_enqueued_reports()
        self.device.write(buf)
        buf = bytes(self.device.read(_RESPONSE_LENGTH))
        return buf
示例#3
0
class CommanderPro(UsbHidDriver):
    """Corsair Commander Pro LED and fan hub"""

    SUPPORTED_DEVICES = [
        (0x1B1C, 0x0C10, None, 'Corsair Commander Pro (experimental)', {
            'fan_count': 6,
            'temp_probs': 4,
            'led_channels': 2
        }),
        (0x1B1C, 0x0C0B, None, 'Corsair Lighting Node Pro (experimental)', {
            'fan_count': 0,
            'temp_probs': 0,
            'led_channels': 2
        }),
    ]

    def __init__(self, device, description, fan_count, temp_probs,
                 led_channels, **kwargs):
        super().__init__(device, description, **kwargs)

        # the following fields are only initialized in connect()
        self._data = None
        self._fan_names = [f'fan{i+1}' for i in range(fan_count)]
        self._led_names = [f'led{i+1}' for i in range(led_channels)]
        self._temp_probs = temp_probs
        self._fan_count = fan_count

    def connect(self, **kwargs):
        """Connect to the device."""
        super().connect(**kwargs)
        ids = f'vid{self.vendor_id:04x}_pid{self.product_id:04x}'
        # must use the HID path because there is no serial number; however,
        # these can be quite long on Windows and macOS, so only take the
        # numbers, since they are likely the only parts that vary between two
        # devices of the same model
        loc = 'loc' + '_'.join(re.findall(r'\d+', self.address))
        self._data = RuntimeStorage(key_prefixes=[ids, loc])

    def initialize(self, **kwargs):
        """Initialize the device and get the fan modes.

        The device should be initialized every time it is powered on, including when
        the system resumes from suspending to memory.

        Returns a list of `(property, value, unit)` tuples.
        """

        res = self._send_command(_CMD_GET_FIRMWARE)
        fw_version = (res[1], res[2], res[3])

        res = self._send_command(_CMD_GET_BOOTLOADER)
        bootloader_version = (
            res[1], res[2])  # is it possible for there to be a third value?

        status = [
            ('Firmware version', '%d.%d.%d' % fw_version, ''),
            ('Bootloader version', '%d.%d' % bootloader_version, ''),
        ]

        if self._temp_probs > 0:
            res = self._send_command(_CMD_GET_TEMP_CONFIG)
            temp_connected = res[1:5]
            self._data.store('temp_sensors_connected', temp_connected)
            status += [
                ('Temp sensor 1',
                 'Connected' if temp_connected[0] else 'Not Connected', ''),
                ('Temp sensor 2',
                 'Connected' if temp_connected[1] else 'Not Connected', ''),
                ('Temp sensor 3',
                 'Connected' if temp_connected[2] else 'Not Connected', ''),
                ('Temp sensor 4',
                 'Connected' if temp_connected[3] else 'Not Connected', ''),
            ]

        if self._fan_count > 0:
            # get the information about how the fans are connected, probably want to save this for later
            res = self._send_command(_CMD_GET_FAN_MODES)
            fanModes = res[1:self._fan_count + 1]
            self._data.store('fan_modes', fanModes)
            status += [
                ('Fan 1 Mode', _get_fan_mode_description(fanModes[0]), ''),
                ('Fan 2 Mode', _get_fan_mode_description(fanModes[1]), ''),
                ('Fan 3 Mode', _get_fan_mode_description(fanModes[2]), ''),
                ('Fan 4 Mode', _get_fan_mode_description(fanModes[3]), ''),
                ('Fan 5 Mode', _get_fan_mode_description(fanModes[4]), ''),
                ('Fan 6 Mode', _get_fan_mode_description(fanModes[5]), ''),
            ]

        return status

    def get_status(self, **kwargs):
        """Get a status report.

        Returns a list of `(property, value, unit)` tuples.
        """

        if self.device.product_id != 0x0c10:
            LOGGER.debug('only the commander pro supports this')
            return []

        connected_temp_sensors = self._data.load('temp_sensors_connected',
                                                 default=[0] *
                                                 self._temp_probs)
        fan_modes = self._data.load('fan_modes', default=[0] * self._fan_count)

        # get the tempature sensor values
        temp = [0] * self._temp_probs
        for num, enabled in enumerate(connected_temp_sensors):
            if enabled:
                temp[num] = self._get_temp(num)

        # get the real power supply voltages
        res = self._send_command(_CMD_GET_VOLTS, [0])
        volt_12 = u16be_from(res, offset=1) / 1000

        res = self._send_command(_CMD_GET_VOLTS, [1])
        volt_5 = u16be_from(res, offset=1) / 1000

        res = self._send_command(_CMD_GET_VOLTS, [2])
        volt_3 = u16be_from(res, offset=1) / 1000

        # get fan RPMs of connected fans
        fanspeeds = [0] * self._fan_count
        for fan_num, mode in enumerate(fan_modes):
            if mode == _FAN_MODE_DC or mode == _FAN_MODE_PWM:
                fanspeeds[fan_num] = self._get_fan_rpm(fan_num)

        status = [
            ('12 volt rail', volt_12, 'V'),
            ('5 volt rail', volt_5, 'V'),
            ('3.3 volt rail', volt_3, 'V'),
        ]

        for temp_num in range(self._temp_probs):
            status += [(f'Temp sensor {temp_num + 1}', temp[temp_num], '°C')]

        for fan_num in range(self._fan_count):
            status += [(f'Fan {fan_num + 1} speed', fanspeeds[fan_num], 'rpm')]

        return status

    def _get_temp(self, sensor_num):
        """This will get the tempature in degrees celsius for the specified temp sensor.

        sensor number MUST be in range of 0-3
        """

        if self._temp_probs == 0:
            raise ValueError(f'this device does not have a tempature sensor')

        if sensor_num < 0 or sensor_num > 3:
            raise ValueError(
                f'sensor_num {sensor_num} invalid, must be between 0 and 3')

        res = self._send_command(_CMD_GET_TEMP, [sensor_num])
        temp = u16be_from(res, offset=1) / 100

        return temp

    def _get_fan_rpm(self, fan_num):
        """This will get the rpm value of the fan.

        fan number MUST be in range of 0-5
        """

        if self._fan_count == 0:
            raise ValueError(f'this device does not have any fans')

        if fan_num < 0 or fan_num > 5:
            raise ValueError(
                f'fan_num {fan_num} invalid, must be between 0 and 5')

        res = self._send_command(_CMD_GET_FAN_RPM, [fan_num])
        speed = u16be_from(res, offset=1)

        return speed

    def _get_hw_fan_channels(self, channel):
        """This will get a list of all the fan channels that the command should be sent to
        It will look up the name of the fan channel given and return a list of the real fan number
        """
        channel = channel.lower()
        if channel == 'sync':
            return [i for i in range(len(self._fan_names))]
        elif channel in self._fan_names:
            return [self._fan_names.index(channel)]
        else:
            raise ValueError(
                f'unknown channel, should be one of: {_quoted("sync", *self._fan_names)}'
            )

    def _get_hw_led_channels(self, channel):
        """This will get a list of all the led channels that the command should be sent to
        It will look up the name of the led channel given and return a list of the real led device number
        """
        channel = channel.lower()
        if channel == 'led':
            return [i for i in range(len(self._led_names))]
        elif channel in self._led_names:
            return [self._led_names.index(channel)]
        else:
            raise ValueError(
                f'unknown channel, should be one of: {_quoted("led", *self._led_names)}'
            )

    def set_fixed_speed(self, channel, duty, **kwargs):
        """Set fan or fans to a fixed speed duty.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Different commands for sending fixed percent (0x23) and fixed rpm (0x24)
        Probably want to use fixed percent for this untill the rpm flag is enabled.
        Can only send one fan command at a time, if fan mode is unset will need to send 6?
        messages (or 1 per enabled fan)
        """

        if self._fan_count == 0:
            raise NotSupportedByDevice()

        duty = clamp(duty, 0, 100)
        fan_channels = self._get_hw_fan_channels(channel)
        fan_modes = self._data.load('fan_modes', default=[0] * self._fan_count)

        for fan in fan_channels:
            mode = fan_modes[fan]
            if mode == _FAN_MODE_DC or mode == _FAN_MODE_PWM:
                self._send_command(_CMD_SET_FAN_DUTY, [fan, duty])

    def set_speed_profile(self,
                          channel,
                          profile,
                          temperature_sensor=1,
                          **kwargs):
        """Set fan or fans to follow a speed duty profile.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Up to six (temperature, duty) pairs can be supplied in `profile`,
        with temperatures in Celsius and duty values in percentage.  The last
        point should set the fan to 100% duty cycle, or be omitted; in the
        latter case the fan will be set to max out at 60°C.
        """

        # send fan num, temp sensor, check to make sure it is actually enabled, and do not let the user send external sensor
        # 6 2-byte big endian temps (celsius * 100), then 6 2-byte big endian rpms
        # need to figure out how to find out what the max rpm is for the given fan

        if self._fan_count == 0:
            raise NotSupportedByDevice()

        profile = list(profile)

        criticalTemp = _CRITICAL_TEMPERATURE_HIGH if check_unsafe(
            'high_tempature', **kwargs) else _CRITICAL_TEMPERATURE
        profile = _prepare_profile(profile, criticalTemp)

        # fan_type = kwargs['fan_type'] # need to make sure this is set
        temp_sensor = clamp(temperature_sensor, 1, self._temp_probs)

        sensors = self._data.load('temp_sensors_connected',
                                  default=[0] * self._temp_probs)

        if sensors[temp_sensor - 1] != 1:
            raise ValueError('the specified tempature sensor is not connected')

        buf = bytearray(26)
        buf[1] = temp_sensor - 1  # 0  # use temp sensor 1

        for i, entry in enumerate(profile):
            temp = entry[0] * 100
            rpm = entry[1]

            # convert both values to 2 byte big endian values
            buf[2 + i * 2] = temp.to_bytes(2, byteorder='big')[0]
            buf[3 + i * 2] = temp.to_bytes(2, byteorder='big')[1]
            buf[14 + i * 2] = rpm.to_bytes(2, byteorder='big')[0]
            buf[15 + i * 2] = rpm.to_bytes(2, byteorder='big')[1]

        fan_channels = self._get_hw_fan_channels(channel)
        fan_modes = self._data.load('fan_modes', default=[0] * self._fan_count)

        for fan in fan_channels:
            mode = fan_modes[fan]
            if mode == _FAN_MODE_DC or mode == _FAN_MODE_PWM:
                buf[0] = fan
                self._send_command(_CMD_SET_FAN_PROFILE, buf)

    def set_color(self,
                  channel,
                  mode_str,
                  colors,
                  direction='forward',
                  speed='medium',
                  start_led=1,
                  maximum_leds=1,
                  **kwargs):
        """Set the color of each LED.

        In reality the device does not have the concept of different channels
        or modes, but this driver provides a few for convenience.  Animations
        still require successive calls to this API.

        The 'led' channel can be used to address individual LEDs, and supports
        the 'super-fixed', 'fixed' and 'off' modes.

        In 'super-fixed' mode, each color in `colors` is applied to one
        individual LED, successively.  LEDs for which no color has been
        specified default to off/solid black.  This is closest to how the
        device works.

        In 'fixed' mode, all LEDs are set to the first color taken from
        `colors`.  The `off` mode is equivalent to calling this function with
        'fixed' and a single solid black color in `colors`.

        The `colors` argument should be an iterable of one or more `[red, blue,
        green]` triples, where each red/blue/green component is a value in the
        range 0–255.

        The table bellow summarizes the available channels, modes, and their
        associated maximum number of colors for each device family.

        | Channel  | Mode        | Num colors |
        | -------- | ----------- | ---------- |
        | led      | off         |          0 |
        | led      | fixed       |          1 |
        | led      | color_shift |          2 |
        | led      | color_pulse |          2 |
        | led      | color_wave  |          2 |
        | led      | visor       |          2 |
        | led      | blink       |          2 |
        | led      | marquee     |          1 |
        | led      | sequential  |          1 |
        | led      | rainbow     |          0 |
        | led      | rainbow2    |          0 |

        """

        # a special mode to clear the current led settings.
        # this is usefull if the the user wants to use a led mode for multiple devices
        if mode_str == 'clear':
            self._data.store('saved_effects', None)
            return

        colors = list(colors)
        expanded = colors[:3]
        c = itertools.chain(*((r, g, b) for r, g, b in expanded))
        colors = list(c)

        direction = direction.lower()
        speed = speed.lower()
        channel = channel.lower()
        mode = mode_str.lower()

        # default to channel 1 if channel 2 is not specified.
        led_channel = 1 if channel == 'led2' else 0

        direction = _LED_DIRECTION_FORWARD if direction == 'forward' else _LED_DIRECTION_BACKWARD
        speed = _LED_SPEED_SLOW if speed == 'slow' else _LED_SPEED_FAST if speed == 'fast' else _LED_SPEED_MEDIUM
        start_led = clamp(start_led, 1, 96) - 1
        num_leds = clamp(
            maximum_leds, 1, 96 - start_led - 1
        )  # there is a current firmware limitation of 96 led's per channel
        random_colors = 0x00 if mode_str == 'off' or len(colors) != 0 else 0x01
        mode = _MODES.get(mode, -1)

        if mode == -1:
            raise ValueError(f'mode "{mode_str}" is not valid')

        lighting_effect = {
            'channel': led_channel,
            'start_led': start_led,
            'num_leds': num_leds,
            'mode': mode,
            'speed': speed,
            'direction': direction,
            'random_colors': random_colors,
            'colors': colors
        }

        saved_effects = [] if mode_str == 'off' else self._data.load(
            'saved_effects', default=[])
        saved_effects += [lighting_effect]

        self._data.store('saved_effects',
                         None if mode_str == 'off' else saved_effects)

        # start sending the led commands
        self._send_command(_CMD_RESET_LED_CHANNEL, [led_channel])
        self._send_command(_CMD_BEGIN_LED_EFFECT, [led_channel])
        self._send_command(_CMD_SET_LED_CHANNEL_STATE, [led_channel, 0x01])

        for effect in saved_effects:
            config = [
                effect.get('channel'),
                effect.get('start_led'),
                effect.get('num_leds'),
                effect.get('mode'),
                effect.get('speed'),
                effect.get('direction'),
                effect.get('random_colors'), 0xff
            ] + effect.get('colors')
            self._send_command(_CMD_LED_EFFECT, config)

        self._send_command(_CMD_LED_COMMIT, [0xff])

    def _send_command(self, command, data=None):
        # self.device.write expects buf[0] to be the report number or 0 if not used
        buf = bytearray(_REPORT_LENGTH + 1)
        buf[1] = command
        start_at = 2

        if data:
            data = data[:_REPORT_LENGTH - 1]
            buf[start_at:start_at + len(data)] = data

        self.device.clear_enqueued_reports()
        self.device.write(buf)
        buf = bytes(self.device.read(_RESPONSE_LENGTH))
        return buf
示例#4
0
class CoolitPlatinumDriver(UsbHidDriver):
    """liquidctl driver for Corsair Platinum and PRO XT coolers."""

    SUPPORTED_DEVICES = [
        (0x1B1C, 0x0C18, None, 'Corsair H100i Platinum (experimental)', {
            'fan_count': 2,
            'rgb_fans': True
        }),
        (0x1B1C, 0x0C19, None, 'Corsair H100i Platinum SE (experimental)', {
            'fan_count': 2,
            'rgb_fans': True
        }),
        (0x1B1C, 0x0C17, None, 'Corsair H115i Platinum (experimental)', {
            'fan_count': 2,
            'rgb_fans': True
        }),
        (0x1B1C, 0x0C20, None, 'Corsair H100i PRO XT (experimental)', {
            'fan_count': 2,
            'rgb_fans': False
        }),
        (0x1B1C, 0x0C21, None, 'Corsair H115i PRO XT (experimental)', {
            'fan_count': 2,
            'rgb_fans': False
        }),
    ]

    def __init__(self, device, description, fan_count, rgb_fans, **kwargs):
        super().__init__(device, description, **kwargs)
        self._component_count = 1 + fan_count * rgb_fans
        self._fan_names = [f'fan{i + 1}' for i in range(fan_count)]
        self._maxcolors = {
            ('led', 'super-fixed'): self._component_count * 8,
            ('led', 'off'): 0,
            ('sync', 'fixed'): self._component_count,
            ('sync', 'super-fixed'): 8,
            ('sync', 'off'): 0,
        }
        # the following fields are only initialized in connect()
        self._data = None
        self._sequence = None

    def connect(self, **kwargs):
        """Connect to the device."""
        super().connect(**kwargs)
        ids = f'{self.vendor_id:04x}_{self.product_id:04x}'
        self._data = RuntimeStorage(key_prefixes=[ids, self.address])
        self._sequence = _sequence(self._data)

    def initialize(self, pump_mode='balanced', **kwargs):
        """Initialize the device and set the pump mode.

        The device should be initialized every time it is powered on, including when
        the system resumes from suspending to memory.

        Valid values for `pump_mode` are 'quiet', 'balanced' and 'extreme'.
        Unconfigured fan channels may default to 100% duty.  Subsequent calls
        should leave the fan speeds unaffected.

        Returns a list of `(property, value, unit)` tuples.
        """
        self._data.store('pump_mode', _PumpMode[pump_mode.upper()].value)
        res = self._send_set_cooling()
        fw_version = (res[2] >> 4, res[2] & 0xf, res[3])
        return [('Firmware version', '%d.%d.%d' % fw_version, '')]

    def get_status(self, **kwargs):
        """Get a status report.

        Returns a list of `(property, value, unit)` tuples.
        """
        res = self._send_command(_FEATURE_COOLING, _CMD_GET_STATUS)
        assert len(self._fan_names
                   ) == 2, f'cannot yet parse with {len(self._fan_names)} fans'
        return [
            ('Liquid temperature', res[8] + res[7] / 255, '°C'),
            ('Fan 1 speed', u16le_from(res, offset=15), 'rpm'),
            ('Fan 2 speed', u16le_from(res, offset=22), 'rpm'),
            ('Pump speed', u16le_from(res, offset=29), 'rpm'),
        ]

    def set_fixed_speed(self, channel, duty, **kwargs):
        """Set fan or fans to a fixed speed duty.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.
        """
        for hw_channel in self._get_hw_fan_channels(channel):
            self._data.store(f'{hw_channel}_mode', _FanMode.FIXED_DUTY.value)
            self._data.store(f'{hw_channel}_duty', duty)
        self._send_set_cooling()

    def set_speed_profile(self, channel, profile, **kwargs):
        """Set fan or fans to follow a speed duty profile.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Up to seven (temperature, duty) pairs can be supplied in `profile`,
        with temperatures in Celsius and duty values in percentage.  The last
        point should set the fan to 100% duty cycle, or be omitted; in the
        latter case the fan will be set to max out at 60°C.
        """
        profile = list(profile)
        for hw_channel in self._get_hw_fan_channels(channel):
            self._data.store(f'{hw_channel}_mode',
                             _FanMode.CUSTOM_PROFILE.value)
            self._data.store(f'{hw_channel}_profile', profile)
        self._send_set_cooling()

    def set_color(self, channel, mode, colors, **kwargs):
        """Set the color of each LED.

        In reality the device does not have the concept of different channels
        or modes, but this driver provides a few for convenience.  Animations
        still require successive calls to this API.

        The 'led' channel can be used to address individual LEDs.  The only
        supported mode for this channel is 'super-fixed', and each color in
        `colors` is applied to one individual LED, successively.  This is
        closest to how the device works.

        The 'sync' channel considers that the individual LEDs are associated
        with components, and provides two distinct convenience modes: 'fixed'
        allows each component to be set to a different color, which is applied
        to all LEDs on that component; very differently, 'super-fixed' allows
        each individual LED to have a different color, but all components are
        made to repeat the same pattern.

        Both channels additionally support an 'off' mode, which is equivalent
        to setting all LEDs to off/solid black.

        `colors` should be an iterable of one or more `[red, blue, green]`
        triples, where each red/blue/green component is a value in the range
        0–255.  LEDs for which no color has been specified will default to
        off/solid black.

        The table bellow summarizes the available channels, modes, and their
        associated maximum number of colors.

        | Channel  | Mode        | LEDs         | Components   | Platinum | PRO XT |
        | -------- | ----------- | ------------ | ------------ | -------- | ------ |
        | sync/led | off         | all off      | all off      |        0 |      0 |
        | sync     | fixed       | synchronized | independent  |        3 |      1 |
        | sync     | super-fixed | independent  | synchronized |        8 |      8 |
        | led      | super-fixed | independent  | independent  |       24 |      8 |
        """
        channel, mode, colors = channel.lower(), mode.lower(), list(colors)
        maxcolors = self._check_color_args(channel, mode, colors)
        if mode == 'off':
            expanded = []
        elif (channel, mode) == ('led', 'super-fixed'):
            expanded = colors[:maxcolors]
        elif (channel, mode) == ('sync', 'fixed'):
            expanded = list(
                itertools.chain(*([color] * 8
                                  for color in colors[:maxcolors])))
        elif (channel, mode) == ('sync', 'super-fixed'):
            expanded = (colors[:8] + [[0, 0, 0]] *
                        (8 - len(colors))) * self._component_count
        else:
            assert False, 'assumed unreacheable'
        data1 = bytes(
            itertools.chain(*((b, g, r) for r, g, b in expanded[0:20])))
        data2 = bytes(
            itertools.chain(*((b, g, r) for r, g, b in expanded[20:])))
        self._send_command(_FEATURE_LIGHTING, _CMD_SET_LIGHTING1, data=data1)
        self._send_command(_FEATURE_LIGHTING, _CMD_SET_LIGHTING2, data=data2)

    def _check_color_args(self, channel, mode, colors):
        maxcolors = self._maxcolors.get((channel, mode))
        if maxcolors is None:
            raise ValueError(
                'Unsupported (channel, mode), should be one of: {_quoted(*self._maxcolors)}'
            )
        if len(colors) > maxcolors:
            LOGGER.warning('too many colors, dropping to %d', maxcolors)
        return maxcolors

    def _get_hw_fan_channels(self, channel):
        channel = channel.lower()
        if channel == 'fan':
            return self._fan_names
        if channel in self._fan_names:
            return [channel]
        raise ValueError(
            f'Unknown channel, should be one of: {_quoted("fan", *self._fan_names)}'
        )

    def _send_command(self, feature, command, data=None):
        # self.device.write expects buf[0] to be the report number or 0 if not used
        buf = bytearray(_REPORT_LENGTH + 1)
        buf[1] = _WRITE_PREFIX
        buf[2] = next(self._sequence) << 3
        if feature is not None:
            buf[2] |= feature
            buf[3] = command
            start_at = 4
        else:
            buf[2] |= command
            start_at = 3
        if data:
            buf[start_at:start_at + len(data)] = data
        buf[-1] = compute_pec(buf[2:-1])
        self.device.clear_enqueued_reports()
        self.device.write(buf)
        buf = bytes(self.device.read(_REPORT_LENGTH))
        self.device.release()
        if compute_pec(buf[1:]):
            LOGGER.warning('response checksum does not match data')
        return buf

    def _send_set_cooling(self):
        assert len(self._fan_names) <= 2, 'cannot yet fit all fan data'
        data = bytearray(_SET_COOLING_DATA_LENGTH)
        data[0:len(_SET_COOLING_DATA_PREFIX)] = _SET_COOLING_DATA_PREFIX
        data[_PROFILE_LENGTH_OFFSET] = _PROFILE_LENGTH
        for fan, (imode, iduty, iprofile) in zip(self._fan_names,
                                                 _FAN_OFFSETS):
            mode = _FanMode(self._data.load(f'{fan}_mode', of_type=int))
            if mode is _FanMode.FIXED_DUTY:
                stored = self._data.load(f'{fan}_duty',
                                         of_type=int,
                                         default=100)
                duty = clamp(stored, 0, 100)
                data[iduty] = fraction_of_byte(percentage=duty)
                LOGGER.info('setting %s to %d%% duty cycle', fan, duty)
            elif mode is _FanMode.CUSTOM_PROFILE:
                stored = self._data.load(f'{fan}_profile',
                                         of_type=list,
                                         default=[])
                profile = _prepare_profile(
                    stored)  # ensures correct len(profile)
                pairs = ((temp, fraction_of_byte(percentage=duty))
                         for temp, duty in profile)
                data[iprofile:iprofile +
                     _PROFILE_LENGTH * 2] = itertools.chain(*pairs)
                LOGGER.info('setting %s to follow profile %r', fan, profile)
            else:
                raise ValueError(f'Unsupported fan {mode}')
            data[imode] = mode.value
        pump_mode = _PumpMode(self._data.load('pump_mode', of_type=int))
        data[_PUMP_MODE_OFFSET] = pump_mode.value
        LOGGER.info('setting pump mode to %s', pump_mode.name.lower())
        return self._send_command(_FEATURE_COOLING,
                                  _CMD_SET_COOLING,
                                  data=data)
示例#5
0
class CoolitDriver(UsbHidDriver):
    """liquidctl driver for Corsair H110i GT cooler"""

    SUPPORTED_DEVICES = [
        (0x1B1C, 0x0C04, None, 'Corsair H110i GT (experimental)',
            {'fan_count': 2, 'rgb_fans': False}),
    ]

    def __init__(self, device, description, fan_count, rgb_fans, **kwargs):
        super().__init__(device, description, **kwargs)
        self._component_count = 1 + fan_count * rgb_fans
        self._fan_names = [f'fan{i + 1}' for i in range(fan_count)]
        
        # the following fields are only initialized in connect()
        self._data = None
        self._sequence = None

    def connect(self, **kwargs):
        """Connect to the device."""
        super().connect(**kwargs)
        ids = f'{self.vendor_id:04x}_{self.product_id:04x}'
        self._data = RuntimeStorage(key_prefixes=[ids, self.address])
        self._sequence = _sequence(self._data)

    def initialize(self, pump_mode='quiet', **kwargs):
        """Initialize the device and set the pump mode

        The device should be initialized every time it is powered on, including when
        the system resumes from suspending to memory.

        Valid values for `pump_mode` are 'quiet' and 'extreme'.
        Unconfigured fan channels may default to 100% duty.

        Returns a list of `(property, value, unit)` tuples.
        """
        self._data.store('pump_mode', _PumpMode[pump_mode.upper()].value)

        dataPackages =  list()
        dataPackages.append(self._build_data_package(_COMMAND_FIRMWARE_ID, _OP_CODE_READ_TWO_BYTES))

        res = self._send_command(dataPackages)

        fw_version = (res[3] >> 4, res[3] & 0xf, res[2])
        return [('Firmware version', '%d.%d.%d' % fw_version, '')]

    def get_status(self, **kwargs):
        """Get a status report.

        Returns a list of `(property, value, unit)` tuples.
        """
        dataPackages =  list()
        dataPackages.append(self._build_data_package(_COMMAND_TEMP_READ, _OP_CODE_READ_TWO_BYTES))
        dataPackages.append(self._build_data_package(_COMMAND_FAN_SELECT, _OP_CODE_WRITE_ONE_BYTE, params=bytes([0])))
        dataPackages.append(self._build_data_package(_COMMAND_FAN_READ_RPM, _OP_CODE_READ_TWO_BYTES))
        dataPackages.append(self._build_data_package(_COMMAND_FAN_SELECT, _OP_CODE_WRITE_ONE_BYTE, params=bytes([1])))
        dataPackages.append(self._build_data_package(_COMMAND_FAN_READ_RPM, _OP_CODE_READ_TWO_BYTES))
        dataPackages.append(self._build_data_package(_COMMAND_FAN_SELECT, _OP_CODE_WRITE_ONE_BYTE, params=bytes([2])))
        dataPackages.append(self._build_data_package(_COMMAND_FAN_READ_RPM, _OP_CODE_READ_TWO_BYTES))

        res = self._send_command(dataPackages)

        temp = res[3] + res[2] / 255

        return [
            ('Liquid temperature', temp, '°C'),
            ('Fan 1 speed', u16le_from(res, offset=8), 'rpm'),
            ('Fan 2 speed', u16le_from(res, offset=14), 'rpm'),
            ('Pump speed', u16le_from(res, offset=20), 'rpm'),
        ]

    def set_fixed_speed(self, channel, duty, **kwargs):
        """Set fan or fans to a fixed speed duty.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.
        """
        for hw_channel in self._get_hw_fan_channels(channel):
            self._data.store(f'{hw_channel}_mode', _FanMode.FIXED_DUTY.value)
            self._data.store(f'{hw_channel}_duty', duty)
        self._send_set_cooling()

    def set_speed_profile(self, channel, profile, **kwargs):
        """Set fan or fans to follow a speed duty profile.

        Valid channel values are 'fanN', where N >= 1 is the fan number, and
        'fan', to simultaneously configure all fans.  Unconfigured fan channels
        may default to 100% duty.

        Up to seven (temperature, duty) pairs can be supplied in `profile`,
        with temperatures in Celsius and duty values in percentage.  The last
        point should set the fan to 100% duty cycle, or be omitted; in the
        latter case the fan will be set to max out at 60°C.
        """
        profile = list(profile)
        for hw_channel in self._get_hw_fan_channels(channel):
            self._data.store(f'{hw_channel}_mode', _FanMode.CUSTOM_PROFILE.value)
            self._data.store(f'{hw_channel}_profile', profile)
        self._send_set_cooling()

    def _get_hw_fan_channels(self, channel):
        channel = channel.lower()
        if channel == 'fan':
            return self._fan_names
        if channel in self._fan_names:
            return [channel]
        raise ValueError(f'Unknown channel, should be one of: {_quoted("fan", *self._fan_names)}')

    def _build_data_package(self, command, opCode, params=None):
        if params:
            buf = bytearray(3 + len(params))
            buf[3 : 3 + len(params)] = params
        else:
            buf = bytearray(3)

        buf[0] = next(self._sequence)
        buf[1] = opCode
        buf[2] = command

        return buf

    def _send_command(self, dataPackages):
        buf = bytearray(_REPORT_LENGTH)

        startIndex = 1
        for dataPackage in dataPackages:
            buf[startIndex : startIndex + len(dataPackage)] = dataPackage
            startIndex += len(dataPackage)

        buf[0] = startIndex - 1
        
        LOGGER.debug('write %s', buf.hex())
        self.device.clear_enqueued_reports()
        self.device.write(buf)
        buf = bytes(self.device.read(_REPORT_LENGTH))
        self.device.release()
        LOGGER.debug('received %s', buf.hex())
        return buf

    def _send_set_cooling(self):
        for fan in self._fan_names:
            fanIndex = 0
            if fan == 'fan1':
                fanIndex = 0
            else:
                fanIndex = 1

            mode = _FanMode(self._data.load(f'{fan}_mode', of_type=int))

            if mode is _FanMode.FIXED_DUTY:
                dataPackages =  list()
                stored = self._data.load(f'{fan}_duty', of_type=int, default=100)
                duty = clamp(stored, 0, 100)
                dataPackages.append(self._build_data_package(_COMMAND_FAN_SELECT, _OP_CODE_WRITE_ONE_BYTE, params=bytes([fanIndex])))
                dataPackages.append(self._build_data_package(_COMMAND_FAN_MODE, _OP_CODE_WRITE_ONE_BYTE, params=bytes([mode.value])))
                dataPackages.append(self._build_data_package(_COMMAND_FAN_FIXED_PWM, _OP_CODE_WRITE_ONE_BYTE, params=bytes([fraction_of_byte(percentage=duty)])))
                LOGGER.info('setting %s to %d%% duty cycle', fan, duty)
                self._send_command(dataPackages)

            elif mode is _FanMode.CUSTOM_PROFILE:
                stored = self._data.load(f'{fan}_profile', of_type=list, default=[])
                profile = _prepare_profile(stored)  # ensures correct len(profile)
                pairs = ((temp, fraction_of_byte(percentage=duty)) for temp, duty in profile)

                fanTemperatureData = list()
                fanTemperatureData.append(0x0A) # 'magical' 0x0A in front of curve definition packages
                
                fanDutyData = list()
                fanDutyData.append(0x0A) # 'magical' 0x0A in front of curve definition packages
                
                for temp, duty in profile:
                    fanTemperatureData.append(0x00)
                    fanTemperatureData.append(temp)
                    rpm = duty * _MAX_FAN_RPM / 100
                    fanDutyData.append(int(rpm % 255))
                    fanDutyData.append(int(rpm - (rpm % 255)) >> 8)
                
                # Send temperature profile
                self._send_command([self._build_data_package(_COMMAND_FAN_TEMP_TABLE, _OP_CODE_WRITE_THREE_BYTES, params=bytes(fanTemperatureData))])
                # Send duty cycle Profile
                self._send_command([self._build_data_package(_COMMAND_FAN_RPM_TABLE, _OP_CODE_WRITE_THREE_BYTES, params=bytes(fanDutyData))])

                # Change mode to custom Profile
                dataPackages =  list()
                dataPackages.append(self._build_data_package(_COMMAND_FAN_SELECT, _OP_CODE_WRITE_ONE_BYTE, params=bytes([fanIndex])))
                dataPackages.append(self._build_data_package(_COMMAND_FAN_MODE, _OP_CODE_WRITE_ONE_BYTE, params=bytes([mode.value])))
                self._send_command(dataPackages)

                LOGGER.info('setting %s to follow profile %r', fan, profile)
            else:
                raise ValueError(f'Unsupported fan {mode}')
        
        pump_mode = _PumpMode(self._data.load('pump_mode', of_type=int))
        
        dataPackages =  list()
        dataPackages.append(self._build_data_package(_COMMAND_FAN_SELECT, _OP_CODE_WRITE_ONE_BYTE, params=bytes([_PUMP_INDEX])))
        if pump_mode == _PumpMode.QUIET:
            dataPackages.append(self._build_data_package(_COMMAND_FAN_FIXED_RPM, _OP_CODE_WRITE_TWO_BYTES, params=bytes(_PUMP_DEFAULT_QUIET)))
        elif pump_mode == _PumpMode.EXTREME:
            dataPackages.append(self._build_data_package(_COMMAND_FAN_FIXED_RPM, _OP_CODE_WRITE_TWO_BYTES, params=bytes(_PUMP_DEFAULT_EXTREME)))
        self._send_command(dataPackages)
        
        LOGGER.info('setting pump mode to %s', pump_mode.name.lower())
示例#6
0
class Legacy690Lc(_CommonAsetekDriver):
    """Legacy fifth generation Asetek 690LC cooler."""

    SUPPORTED_DEVICES = [
        (0x2433, 0xb200, None,
         'Asetek 690LC (assuming NZXT Kraken X) (experimental)', {}),
    ]

    @classmethod
    def probe(cls, handle, legacy_690lc=False, **kwargs):
        if not legacy_690lc:
            return
        yield from super().probe(handle, **kwargs)

    def __init__(self, device, description, **kwargs):
        super().__init__(device, description, **kwargs)
        # --device causes drivers to be instantiated even if they are later
        # discarded; defer instantiating the data storage until to connect()
        self._data = None

    def connect(self, runtime_storage=None, **kwargs):
        super().connect(**kwargs)
        ids = f'vid{self.vendor_id:04x}_pid{self.product_id:04x}'
        loc = f'bus{self.bus}_port{"_".join(map(str, self.port))}'

        if runtime_storage:
            self._data = runtime_storage
        else:
            self._data = RuntimeStorage(key_prefixes=[ids, loc, 'legacy'])

    def _set_all_fixed_speeds(self):
        self._begin_transaction()
        for channel in ['pump', 'fan']:
            mtype, dmin, dmax = _LEGACY_FIXED_SPEED_CHANNELS[channel]
            duty = clamp(
                self._data.load(f'{channel}_duty', of_type=int, default=dmax),
                dmin, dmax)
            _LOGGER.info('setting %s duty to %d%%', channel, duty)
            self._write([mtype, duty])
        return self._end_transaction_and_read()

    def initialize(self, **kwargs):
        super().initialize(**kwargs)
        self._data.store('pump_duty', None)
        self._data.store('fan_duty', None)
        self._set_all_fixed_speeds()

    def get_status(self, **kwargs):
        """Get a status report.

        Returns a list of `(property, value, unit)` tuples.
        """

        msg = self._set_all_fixed_speeds()
        firmware = '{}.{}.{}.{}'.format(*tuple(msg[0x17:0x1b]))
        return [('Liquid temperature', msg[10] + msg[14] / 10, '°C'),
                ('Fan speed', msg[0] << 8 | msg[1], 'rpm'),
                ('Pump speed', msg[8] << 8 | msg[9], 'rpm'),
                ('Firmware version', firmware, '')]

    def set_color(self,
                  channel,
                  mode,
                  colors,
                  time_per_color=None,
                  time_off=None,
                  alert_threshold=_HIGH_TEMPERATURE,
                  alert_color=[255, 0, 0],
                  **kwargs):
        """Set the color mode for a specific channel."""
        # keyword arguments may have been forwarded from cli args and need parsing
        colors = list(colors)
        self._begin_transaction()
        if mode == 'fading':
            if time_per_color is None:
                time_per_color = 5
            self._configure_device(fading=True,
                                   color1=colors[0],
                                   color2=colors[1],
                                   interval1=clamp(time_per_color, 1, 255),
                                   alert_temp=clamp(alert_threshold, 0, 100),
                                   color3=alert_color)
        elif mode == 'blinking':
            if time_per_color is None:
                time_per_color = 1
            if time_off is None:
                time_off = time_per_color
            self._configure_device(blinking=True,
                                   color1=colors[0],
                                   interval1=clamp(time_off, 1, 255),
                                   interval2=clamp(time_per_color, 1, 255),
                                   alert_temp=clamp(alert_threshold, 0, 100),
                                   color3=alert_color)
        elif mode == 'fixed':
            self._configure_device(color1=colors[0],
                                   alert_temp=clamp(alert_threshold, 0, 100),
                                   color3=alert_color)
        elif mode == 'blackout':  # stronger than just 'off', suppresses alerts and rainbow
            self._configure_device(blackout=True,
                                   alert_temp=clamp(alert_threshold, 0, 100),
                                   color3=alert_color)
        else:
            raise KeyError(f'unsupported lighting mode {mode}')
        self._end_transaction_and_read()

    def set_fixed_speed(self, channel, duty, **kwargs):
        """Set channel to a fixed speed duty."""
        mtype, dmin, dmax = _LEGACY_FIXED_SPEED_CHANNELS[channel]
        duty = clamp(duty, dmin, dmax)
        self._data.store(f'{channel}_duty', duty)
        self._set_all_fixed_speeds()

    def set_speed_profile(self, channel, profile, **kwargs):
        """Not supported by this device."""
        raise NotSupportedByDevice