"""liquidctl driver for NZXT E-series PSUs. Supported devices: NZXT E500, E650 and E850. Features: - electrical output monitoring: complete - general device monitoring: partial - fan control: missing - 12V multiple rail configuration: missing Copyright Jonas Malaco and contributors SPDX-License-Identifier: GPL-3.0-or-later """ import time from liquidctl.driver.usb import UsbHidDriver from liquidctl.error import NotSupportedByDevice from liquidctl.pmbus import CommandCode as CMD from liquidctl.pmbus import linear_to_float _REPORT_LENGTH = 64 _MIN_DELAY = 0.0025 _ATTEMPTS = 3 _SEASONIC_READ_FIRMWARE_VERSION = CMD.MFR_SPECIFIC_FC _RAILS = ['+12V peripherals', '+12V EPS/ATX12V', '+12V motherboard/PCI-e', '+5V combined', '+3.3V combined'] class NzxtEPsu(UsbHidDriver): """NZXT E-series power supply unit.""" _MATCHES = [ (0x7793, 0x5911, 'NZXT E500', {}), (0x7793, 0x5912, 'NZXT E650', {}), (0x7793, 0x2500, 'NZXT E850', {}), ] def initialize(self, **kwargs): """Initialize the device. Apparently not required. """ pass def get_status(self, **kwargs): """Get a status report. Returns a list of `(property, value, unit)` tuples. """ self.device.clear_enqueued_reports() fw_human, fw_cam = self._get_fw_versions() status = [ ('Temperature', self._get_float(CMD.READ_TEMPERATURE_2), '°C'), ('Fan speed', self._get_float(CMD.READ_FAN_SPEED_1), 'rpm'), ('Firmware version', f'{fw_human}/{fw_cam}', ''), ] for i, name in enumerate(_RAILS): status.append((f'{name} output voltage', self._get_vout(i), 'V')) status.append((f'{name} output current', self._get_float(CMD.READ_IOUT, page=i), 'A')) status.append((f'{name} output power', self._get_float(CMD.READ_POUT, page=i), 'W')) return status def set_color(self, channel, mode, colors, **kwargs): """Not supported by this device.""" raise NotSupportedByDevice() def set_speed_profile(self, channel, profile, **kwargs): """Not supported by this device.""" raise NotSupportedByDevice() def set_fixed_speed(self, channel, duty, **kwargs): """Not supported by this device.""" raise NotSupportedByDevice() def _write(self, data): assert len(data) <= _REPORT_LENGTH packet = bytearray(1 + _REPORT_LENGTH) packet[1: 1 + len(data)] = data # device doesn't use numbered reports self.device.write(packet) def _read(self): return self.device.read(_REPORT_LENGTH) def _wait(self): """Give the device some time and avoid error responses. Not well understood but probably related to the PIC16F1455 microcontroller. It is possible that it isn't just used for a "dumb" PMBus/HID bridge, requiring time to be left for other tasks. """ time.sleep(_MIN_DELAY) def _exec_read(self, cmd, data_len): data = None msg = [0xad, 0, data_len + 1, 1, 0x60, cmd] for _ in range(_ATTEMPTS): self._wait() self._write(msg) res = self._read() # see comment in _exec_page_plus_read, but res[1] == 0xff has not # been seen in the wild yet # TODO replace with PEC byte check if res[0] == 0xaa and res[1] == data_len + 1: data = res break assert data, f'invalid response (attempts={_ATTEMPTS})' return data[2:(2 + data_len)] def _exec_page_plus_read(self, page, cmd, data_len): data = None msg = [0xad, 0, data_len + 2, 4, 0x60, CMD.PAGE_PLUS_READ, 2, page, cmd] for _ in range(_ATTEMPTS): self._wait() self._write(msg) res = self._read() # in captured traffic res[2] == 0xff appears to signal invalid data # (possibly due to the device being busy, see PMBus spec) # TODO replace with PEC byte check if res[0] == 0xaa and res[1] == data_len + 2 and res[2] == data_len: data = res break assert data, f'invalid response (attempts={_ATTEMPTS})' return data[3:(3 + data_len)] def _get_float(self, cmd, page=None): if page is None: return linear_to_float(self._exec_read(cmd, 2)) else: return linear_to_float(self._exec_page_plus_read(page, cmd, 2)) def _get_vout(self, rail): mode = self._exec_page_plus_read(rail, CMD.VOUT_MODE, 1)[0] assert mode >> 5 == 0 # assume vout_mode is always ulinear16 vout = self._exec_page_plus_read(rail, CMD.READ_VOUT, 2) return linear_to_float(vout, mode & 0x1f) def _get_fw_versions(self): minor, major = self._exec_read(_SEASONIC_READ_FIRMWARE_VERSION, 2) human_ver = f'{bytes([major]).decode()}{minor:03}' ascam_ver = int.from_bytes(bytes.fromhex(human_ver), byteorder='big') return (human_ver, ascam_ver) def set_screen(self, channel, mode, value, **kwargs): """Not supported by this device.""" raise NotSupportedByDevice() # deprecated aliases SeasonicEDriver = NzxtEPsu