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_int(f'{channel}_duty', 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_int('pump_duty', None) self._data.store_int('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_int(f'{channel}_duty', duty) self._set_all_fixed_speeds() def set_speed_profile(self, channel, profile, **kwargs): """Not supported by this device.""" raise NotSupportedByDevice
class HydroPlatinum(UsbHidDriver): """Corsair Hydro Platinum or Pro XT liquid cooler.""" 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._led_count = 16 + 4 * fan_count * rgb_fans 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, **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]) self._sequence = _sequence(self._data) return self 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.upper()].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', '%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, 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 | | -------- | ----------- | ------------ | -------- | ------ | | led | off | synchronized | 0 | 0 | | led | fixed | synchronized | 1 | 1 | | led | super-fixed | independent | 24 | 16 | Note: lighting control of Pro XT devices is experimental and requires the `pro_xt_lighting` constant to be supplied in the `unsafe` iterable. """ if 'Pro XT' in self.description: check_unsafe('pro_xt_lighting', error=True, **kwargs) channel, mode, colors = channel.lower(), mode.lower(), 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:]))) 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): try: mincolors = self._mincolors[(channel, mode)] maxcolors = self._maxcolors[(channel, mode)] except KeyError: raise ValueError( f'Unsupported (channel, mode) pair, should be one of: {_quoted(*self._mincolors)}' ) 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): 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)) 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)
def tmpstore(tmpdir): run_dir = tmpdir.mkdir('run_dir') prefixes = ['prefix'] backend = _FilesystemBackend(key_prefixes=prefixes, runtime_dirs=[run_dir]) return RuntimeStorage(prefixes, backend=backend)
def connect(self, **kwargs): super().connect(**kwargs) ids = 'vid{:04x}_pid{:04x}'.format(self.vendor_id, self.product_id) loc = 'bus{}_port{}'.format(self.bus, '_'.join(map(str, self.port))) self._data = RuntimeStorage(key_prefixes=[ids, loc, 'legacy'])
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)
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]) 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()) 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)
class CommanderPro(UsbHidDriver): """Corsair Commander Pro LED and fan hub""" SUPPORTED_DEVICES = [ (0x1b1c, 0x0c10, None, 'Corsair Commander Pro', { 'fan_count': 6, 'temp_probs': 4, 'led_channels': 2 }), (0x1b1c, 0x0c0b, None, 'Corsair Lighting Node Pro', { 'fan_count': 0, 'temp_probs': 0, 'led_channels': 2 }), (0x1b1c, 0x0c1a, None, 'Corsair Lighting Node Core', { 'fan_count': 0, 'temp_probs': 0, 'led_channels': 1 }), (0x1b1c, 0x1d00, None, 'Corsair Obsidian 1000D', { 'fan_count': 6, 'temp_probs': 4, '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)] 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._fan_count == 0 or self._temp_probs == 0: _LOGGER.debug( 'only Commander Pro and Obsidian 1000D report status') 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' or len(self._fan_names) == 1: return list(range(len(self._fan_names))) if channel in self._fan_names: return [self._fan_names.index(channel)] 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' or len(self._led_names) == 1: return list(range(len(self._led_names))) if channel in self._led_names: return [self._led_names.index(channel)] 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=_MAX_LEDS, **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, _MAX_LEDS) - 1 num_leds = clamp(maximum_leds, 1, _MAX_LEDS - start_led) 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