diff --git a/.gitignore b/.gitignore index 09c1e3cb9..d22ea6a78 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ /dist/ __pycache__/ liquidctl/_version.py -.DS_Store \ No newline at end of file +.DS_Store +.coverage +.vscode/ +venv/ \ No newline at end of file diff --git a/README.md b/README.md index 48b931a23..949051403 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ subjective "from more to less liquid control-ly" order. | AIO liquid cooler | [Corsair iCUE Elite Capellix H100i, H115i, H150i](docs/corsair-commander-core-guide.md) | _ep_ | 1.11.1 | | AIO liquid cooler | [Corsair iCUE Elite RGB H100i](docs/corsair-platinum-pro-xt-guide.md) | _e_ | git | | AIO liquid cooler | [EVGA CLC 120 (CL12), 240, 280, 360](docs/asetek-690lc-guide.md) | _Z_ | 1.9.1 | +| AIO liquid cooler | [MSI MPG Coreliquid K360](docs/msi-mpg-coreliquid-guide.md) | _penx_ | git | | AIO liquid cooler | [NZXT Kraken M22](docs/kraken-x2-m2-guide.md) | | 1.10.0 | | AIO liquid cooler | [NZXT Kraken X40, X60](docs/asetek-690lc-guide.md) | _LZe_ | 1.9.1 | | AIO liquid cooler | [NZXT Kraken X31, X41, X61](docs/asetek-690lc-guide.md) | _LZ_ | 1.9.1 | diff --git a/docs/developer/protocol/coreliquid.md b/docs/developer/protocol/coreliquid.md new file mode 100644 index 000000000..d89461723 --- /dev/null +++ b/docs/developer/protocol/coreliquid.md @@ -0,0 +1,314 @@ +# MSI coreliquid AIO protocol + +### Compatible devices + +| Device Name | USB ID | LED channels | Fan channels | +| ----------- | ------ | ------------ | ------------ | +| MPG Coreliquid K360 | `ODB0:B130` | 1 | 5 | + +### Command formats + +Most of the communication with the K360 uses 64 byte HID reports. Lighting effect control uses a 185 byte feature report. +Incoming and outgoing reports generally share the same size. +Write commands start with a prefix of `0xD0`, and multi-byte numbers are little-endian, unless stated otherwise. + +| Feature report number | Description | +| ---------- | ----------- | +| 0x52 | Get or set board data, notably lighting control | +| 0xD0 | "get all hardware monitor data" (currently unused) | + +It can be noted that the feature report for the board data seems to be designed to include information about all the leds connected to the motherboard. +The driver only needs to set a small subset of the data in order to control the cpu cooler. + +## Get General Information + +### Get APROM Firmware version - `0xB0` + +Request: + +| Byte index | Value | +| ---------- | ----------- | +| 0x00 | 0x01 | +| 0x01 | 0xB0 | +| Fill | 0xCC | + +Response: + +| Byte index | Description | +| ---------- | ----------- | +| 0x02 | X | + +Firmware version is `(X >> 4).(X & 0x0F)` + +### Get LDROM Firmware version - `0xB6` + +Request: + +| Byte index | Value | +| ---------- | ----------- | +| 0x00 | 0x01 | +| 0x01 | 0xB6 | +| Fill | 0xCC | + +Response: + +| Byte index | Description | +| ---------- | ----------- | +| 0x02 | X | + +Firmware version is `(X >> 4).(X & 0x0F)` + +### Get screen Firmware version - `0xF1` + +Response: + +| Byte index | Description | +| ---------- | ----------- | +| 0x02 | Version number | + +### Get device model index - `0xB1` + +Request: + +| Byte index | Value | +| ---------- | ----------- | +| Fill | 0xCC | + +Response: + +| Byte index | Description | +| ---------- | ----------- | +| 0x02 | Version number | + +## Sending system information for fan control + +Fan profiles are **NOT** controlled by any internal device temperature measurement. +Instead, the device expects periodic reports of the CPU temperature, which it uses to interpolate +fan speeds and to show on the screen. + +### Set CPU status - `0x85` + +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0x85 | +| 0x02-0x03 | cpu frequency (int, MHz) | +| 0x01 | cpu temperature (int, C) | + + +### Set GPU status - `0x86` + +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0x86 | +| 0x02-0x03 | gpu memory frequency (int, MHz) | +| 0x01 | gpu usage (int, %) | + + +## Lighting effects + +### Get all board data (lighting) - Feature report `0x52` + +Response: + +| Byte index | Description | +|--------------------------|---------------------------------------------------------| +| 0x1F | **Lighting mode** | +| 0x20-0x22 | **RGB values for color1 in JRainbow1 area** | +| 0x23 | **Bits 0-1: Speed (LOW, MEDIUM, HIGH)** | +| 0x24 | **Bits 2-6: Brightness level (0-10)** | +| 0x24-0x26 | **RGB values for color2 in JRainbow1 area** | +| 0x27 | **Bit 7: Color selection (0: Rainbow, 1: User-defined)**| +| 0x29 | Number of LEDs in JRainbow1 area | +| 0x34 | Number of LEDs in JRainbow2 area | +| 0x3D | Bit 0: Stripe (0) or Fan (1) selection | +| 0x3D | Bits 1-3: Fan type (SP, HD, LL) | +| 0x3E | Bits 2-7: Corsair device quantity (0-63) | +| 0x3F | Number of LEDs in JCorsair area | +| 0x48 | Bit 0: LL120 outer individual mode (0 or 1) | +| 0x4E | Bit 7: Combined JRGB (True or False) | +| 0x52 | Bit 0: Onboard sync (True or False) | +| 0x52 | Bit 1: Combine JRainbow1 | +| 0x52 | Bit 2: Combined JRainbow2 | +| 0x52 | Bit 3: Combined JCorsair | +| 0x52 | Bit 4: Combined JPipe1 | +| 0x52 | Bit 5: Combined JPipe2 | +| 0xB8 | **Save to device (0 or 1)** | + +Ligthing effects are sent to the device by sending the feature report `0x52` with the desired data in the above format. + +| Byte Value | Lighting Effect Name | +|------------|---------------------------| +| 0 | DISABLE | +| 1 | NO_ANIMATION | +| 2 | BREATHING | +| 3 | FLASHING | +| 4 | DOUBLE_FLASHING | +| 5 | LIGHTNING | +| 6 | MSI_MARQUEE | +| 7 | METEOR | +| 8 | WATER_DROP | +| 9 | MSI_RAINBOW | +| 10 | POP | +| 11 | JAZZ | +| 12 | PLAY | +| 13 | MOVIE | +| 14 | MARQUEE | +| 15 | COLOR_RING | +| 16 | PLANETARY | +| 17 | DOUBLE_METEOR | +| 18 | ENERGY | +| 19 | BLINK | +| 20 | CLOCK | +| 21 | COLOR_PULSE | +| 22 | COLOR_SHIFT | +| 23 | COLOR_WAVE | +| 24 | VISOR | +| 25 | RAINBOW | +| 26 | RAINBOW_WAVE | +| 27 | VISOR | +| 28 | JRAINBOW | +| 29 | RAINBOW_FLASHING | +| 30 | RAINBOW_DOUBLE_FLASHING | +| 31 | RANDOM | +| 32 | FAN_CONTROL | +| 33 | DISABLE2 | +| 34 | COLOR_RING_FLASHING | +| 35 | COLOR_RING_DOUBLE_FLASHING| +| 36 | STACK | +| 37 | CORSAIR_IQUE | +| 38 | FIRE | +| 39 | LAVA | +| 40 | END | + + +## Fan control + +### Fan temperature config - `0x33` (Get) or `0x41` (Set) + +Format: + +| Byte index | Description | +| ---------- | ----------- | +| 0x00 | 0xD0 | +| 0x01 | 0x32/0x41 (response/write)| +| 0x02-0x09 | Radiator fan 1 config | +| 0x0A-0x11 | Radiator fan 2 config | +| 0x12-0x19 | Radiator fan 3 config | +| 0x01A-0x21 | Pump speed config | +| 0x22-0x29 | Waterblock fan config | + +A fan temperature config consists of 8 bit integer values: + - Mode index + - 7 temperature points in Celsius. + +### Get fan speed config - `0x32` (Get) or `0x40` (Set) + +Format: + +| Byte index | Description | +| ---------- | ----------- | +| 0x00 | 0xD0 | +| 0x01 | 0x32/0x40 (response/write) | +| 0x02-0x09 | Radiator fan 1 config | +| 0x0A-0x11 | Radiator fan 2 config | +| 0x12-0x19 | Radiator fan 3 config | +| 0x01A-0x21 | Pump speed config | +| 0x22-0x29 | Waterblock fan config | + +A fan config consists of 8 bit integer values: + - Mode index + - 7 duty cycle percentage values. + +### Get current fan status - `0x31` + +Response: + +| Byte index | Description | +| ---------- | ----------- | +| 0x00 | 0xD0 | +| 0x01 | 0x31 | +| 0x02-0x03 | Radiator fan 1 rpm | +| 0x04-0x05 | Radiator fan 2 rpm | +| 0x06-0x07 | Radiator fan 3 rpm | +| 0x08-0x09 | Pump speed rpm | +| 0x0A-0x0B | Waterblock fan rpm | +| 0x16-0x17 | Radiator fan 1 duty % | +| 0x18-0x19 | Radiator fan 2 duty % | +| 0x1A-0x1B | Radiator fan 3 duty % | +| 0x1C-0x1D | Pump speed duty % | +| 0x1E-0x01F | Waterblock fan duty % | + +## Display control + +### Show Hardware Monitor - `0x71` + +The device is capable of displaying a maximum of 3 different parameters, which will cycle on the display. + +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0x71 | +| 0x02 | Show CPU frequency (0 or 1) | +| 0x03 | Show CPU temperature (0 or 1) | +| 0x04 | Show GPU memory frequency (0 or 1) | +| 0x05 | Show GPU usage (0 or 1) | +| 0x06 | Show pump (0 or 1)| +| 0x07 | Show radiator fan (0 or 1) | +| 0x08 | Show waterblock fan (0 or 1) | +| 0x09 | How many radiator fan speeds to show separately (1 or 3) | + +### Set User Message - `0x93` + +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0x93 | +| 0x02-0x3E | Message bytes (ASCII) | +| 0x3F | 0x20 | + +### Set Clock Display - `0x7A` + +Sets the clock display style on the OLED screen. `clock_style` determines the visual style of the clock. + +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0x7A | +| 0x02 | `clock_style` (0, 1 or 2) | + +### Set Brightness and Direction - `0x7E` + +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0x7E | +| 0x02 | brightness (0-100) | +| 0x03 | direction (0-3) | + +## Image Upload Commands + +### Upload Image - `0xC0` (GIF) or `0xD0` (Banner) + +File uploads are initiated by a single report, after which the data is transferred in chunks of 60 bytes. Uploaded images must be 240x320 px size, and in the standard 24-bit color BMP format. A short sleep should be placed between the transfer initiation and start of the data transfer to make sure the device is ready. + +**Transfer initiation report** +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0xC0/0xD0 (GIF/Banner) | +| 0x02-0x05 | file size to be transferred in bytes (uint32) | +| 0x06 | Slot where the image is saved | + +**Bulk transfer report** +| Byte index | Description | +| ---------- | ----------- | +| 0x01 | 0xC1/0xD1 (GIF/Banner) | +| 0x02-0x3D | data chunk | +| 0x3E-0x3F | 0x00 | + + +### Get Image Checksum - `0xC2` (GIF) or `0xD2` (Banner) + +Response: + +| Byte index | Description | +| ---------- | ----------- | +| 0x02-0x03 | checksum value | + + + diff --git a/docs/msi-mpg-coreliquid-guide.md b/docs/msi-mpg-coreliquid-guide.md new file mode 100644 index 000000000..2241e5fb1 --- /dev/null +++ b/docs/msi-mpg-coreliquid-guide.md @@ -0,0 +1,165 @@ +# MSI MPG Coreliquid AIO coolers +_Driver API and source code available in [`liquidctl.driver.msi`](../liquidctl/driver/msi)._ + +_Currently, only the K360 model is experimentally supported as more testing and feedback is needed._ + +This driver is for the MSI MPG coreliquid series of AIO coolers, of which currently only the coreliquid K360 has been tested and verified to be working. The usage of speed profiles for this model requires external periodic updates of the current cpu temperature. As a result, to use variable fan speeds you must be careful to make sure that the current cpu temperature gets sent to the device. An example method to accomplish this is the `--use-device-controller` option in [`extra/yoda`](../extra/yoda). Device configuration, including lighgting and fan profiles persist until a new configuration is sent to the device, but saved settings are lost after power is lost (power state S5). Uploaded display images are saved onto the device, and so they can be accessed by their uploaded type and index even after loss of power, preventing the need to repeatedly upload the same files. The lcd display resets to the default animation when the system is suspended (S3). + +LED lighting is controlled via preset modes, which are sent once to the device as a configuration, after which the device then independently commands the LEDs until a new configuration is received. + +The K360 model includes an LCD screen capable of displaying various preset animations, hardware status, ASCII banners with a preset or custom background image, and preset or custom images. + +## Initialization + +Controlling the device does not always require initialization, but some features, such as changing the display settings may not function before initialization. Initialization on its own will set default fan curves and LCD screen settings. + +## Monitoring + +The AIO unit is able to report fan speeds, pump speed, water block speed, and duties. + +``` +# liquidctl status +MSI MPG Coreliquid K360 +├── Fan 1 speed 1546 rpm +├── Fan 1 duty 60 % +├── Fan 2 speed 1562 rpm +├── Fan 2 duty 60 % +├── Fan 3 speed 1530 rpm +├── Fan 3 duty 60 % +├── Water block speed 2400 rpm +├── Water block duty 50 % +├── Pump speed 2777 rpm +├── Pump duty 100 % +``` +## Fan and pump speeds + +First, some important notes... + +*You must carefully consider what pump and fan speeds to run. Heat output, case airflow, radiator size, installed fans and ambient temperature are some of the factors to take into account. Test your settings under different scenarios, and make sure that they are appropriate, correctly applied and persistent.* + +*The device has no internal temperature measurement to control the fan speeds, and simply running a liquidctl command to set a speed profile will not persistently provide this necessary data to the device. You can use [`extra/yoda`](../extra/yoda) to communicate with the cooler, or create your own service to keep the device updated on the current temperature.* + +*You should also consider monitoring your hardware temperatures and setting alerts for overheating components or pump failures.* + +With those out of the way, the pump speed can be configured to a fixed duty value or with a profile dependent on a (temperature) signal that MUST be periodically sent to the device. + +Fixed speeds can be set by specifying the desired channel and duty value. + +``` +# liquidctl set pump speed 90 +``` + +| Channel | Minimum duty | Maximum duty | +| --- | -- | --- | +| `pump` | 60% | 100% | +| `radiator fan` | 20% | 100% | | +| `waterblock fan` | 0% | 100% | | + +For profiles, one or more temperature–duty pairs are supplied instead of single value. + +``` +# liquidctl set pump speed 20 30 30 50 34 80 40 90 50 100 + ^^^^^ ^^^^^ ^^^^^ ^^^^^ ^^^^^^ + pairs of temperature (°C) -> duty (%) +``` + +liquidctl will normalize and optimize this profile before pushing it to the device. Adding `--verbose` will trace the final profile that is being applied. + +The device also has preset pump/fan curves that can be applied independently for each channel with [`yoda`](../extra/yoda). Perhaps most notable is the "smart" mode, which enables fan-stop for two of the three radiator fans. Fan-stop is locked by the device for custom fan profiles, likely to prevent the liquid from overheating. + +The preset device profiles are: + + - Silent + - Balance + - Game + - Default + - Smart + +The preset, named modes are supported in the driver and they currently have experimental support in [`yoda`](../extra/yoda), support in the liquidctl cli is on the way. + +## RGB lighting with LEDs + +LEDs on the device are always synced with the same effect, so the channel argument is unused when setting colors. + +Colors can be specified in RGB, HSV or HSL (see [Supported color specification formats](../README.md#supported-color-specification-formats)). Each animation mode supports zero to two colors, and some animation modes include an additional "rainbow" mode. + +Some lighting modes are intended to react to the sounds currently playing on the system. These modes do not currently function as intended with this driver. + +| Mode | Colors | Rainbow option | Notes | +| --- | --- | --- | --- | +| `disable` | None | None | | +| `steady` | One | No | | +| `blink` | ? | ? | Not working as intended | +| `breathing` | One | No | Yes | +| `clock` | Two | Yes | | +| `color pulse` | ? | ? | Not working as intended | +| `color ring` | None | None | | +| `color ring double flashing` | None | None | | +| `color ring flashing` | None | None | | +| `color shift` | None | None | Not working as intended | +| `color wave` | Two | Yes | | +| `corsair ique` | ? | ? | Unclear | +| `disable2` | None | None | | +| `double flashing` | One | Yes | | +| `double meteor` | None | None | | +| `energy` | None | None | | +| `fan control` | ? | ? | Not working as intended | +| `fire` | Two | No | | +| `flashing` | One | Yes | | +| `jazz` | ? | ? | Not working as intended | +| `jrainbow` | ? | ? | Unclear | +| `lava` | ? | ? | Not working as intended | +| `lightning` | One | No | | +| `marquee` | One | No | | +| `meteor` | One | Yes | | +| `movie` | ? | ? | Not working as intended | +| `msi marquee` | One | Yes | | +| `msi rainbow` | ? | ? | Not working as intended | +| `planetary` | None | None | | +| `play` | ? | ? | Not working as intended | +| `pop` | ? | ? | Not working as intended | +| `rainbow` | ? | ? | Very jittery and slow, rainbow wave is recommended instead | +| `rainbow double flashing` | None | None | | +| `rainbow flashing` | None | None | | +| `rainbow wave` | None | None | | +| `random` | None | None | | +| `rap` | ? | ? | Not working as intended | +| `stack` | One | Yes | | +| `visor` | Two | Yes | | +| `water drop` | One | Yes | | + +Support for the rainbow option in the liquidctl cli is on the way, but it is included in the driver. + +## The LCD screen + +The screen resolution is 320 x 240 px, and custom images uploaded with this driver are resized to fit this requirement. The screen orientation and brightness (0-10) can also be controlled. The only channel available for the K360 model is "lcd". + +Maximum length of the displayed banner mesages is 62 ASCII characters. hardware status display functionality is limited, as the displayed data must be communicated to the device. This functionality is implemented in the driver, but currently its usage is limited to yoda, which gpu-unaware so the gpu_freq and gpu_usage parameters will not display correct information without custom update services. + + +| mode name | action | options | +| --- | --- | --- | +| hardware | set the screen to display hardware info | up to 3 semicolon delimited keys from the available sensors | +| image | set the screen to display a custom or preset image | \;\[;\] | +| banner | set the screen to display a message with custom or preset image as background | \;\;\[;\] +| clock | set the screen to display system time (requires control service to send the time to the device) | integer between 0 and 2 to specify the style of the clock display | +| settings | set the screen brightness and orientation | \;\ | +| disable | disables the lcd screen | | + +| Display orientation | value | +| --- | --- | +| Default (up) | 0 | +| Right | 1 | +| Down | 2 | +| Left | 3 | + + +| Displayed sensor data | Notes | +| --- | --- | +| cpu_freq | | +| cpu_temp | this is the sensor value that controls set profile fan duties | +| gpu_freq | Used by the manufacturer to display gpu memory frequency | +| gpu_usage | | +| fan_pump | | +| fan_radiator | | +| fan_cpumos | waterblock fan speed | diff --git a/extra/linux/71-liquidctl.rules b/extra/linux/71-liquidctl.rules index 5838e0595..8baf4980e 100644 --- a/extra/linux/71-liquidctl.rules +++ b/extra/linux/71-liquidctl.rules @@ -458,6 +458,9 @@ SUBSYSTEMS=="usb", ATTRS{idVendor}=="048d", ATTRS{idProduct}=="5702", TAG+="uacc # Gigabyte RGB Fusion 2.0 8297 Controller SUBSYSTEMS=="usb", ATTRS{idVendor}=="048d", ATTRS{idProduct}=="8297", TAG+="uaccess" +# MSI MPG Coreliquid K360 +SUBSYSTEMS=="usb", ATTRS{idVendor}=="0db0", ATTRS{idProduct}=="b130", TAG+="uaccess" + # NZXT E500 SUBSYSTEMS=="usb", ATTRS{idVendor}=="7793", ATTRS{idProduct}=="5911", TAG+="uaccess" diff --git a/extra/yoda b/extra/yoda index b0bb30f61..76385cf7e 100755 --- a/extra/yoda +++ b/extra/yoda @@ -14,6 +14,11 @@ Profiles are specified as comma-separated lists of `(temperature,duty)` pairs. For example: `(20,50),(40,65),(40,65),(50,100)` specifies a duty of 65% at 40°C. The profile will be linearly interpolated between the specified points. +In device controlled mode, sets the device internal control profile and periodically +sends sensor data, but the device will independently control duty cycles to match the temperature. +Named profiles implemented by the device manufacturer, such as "silent", "game", "smart", +are only available in device controlled mode. + Escape sequences or appropriate single or double quotes should be employed to escape characters that are reserved by the shell in use (e.g. in the case of bash, the parenthesis and any optional whitespace). In practice, wrapping the @@ -24,6 +29,7 @@ Examples: yoda --match grid control fan1 with "(20,20),(35,100)" on nct6793.systin yoda --match kraken show-sensors yoda --match kraken control pump with "(20,50),(50,100)" on istats.cpu and fan with "(20,25),(34,100)" on _internal.liquid + yoda --match msi control pump with "smart" on coretemp.package_id_0 and fans with "silent" on coretemp.package_id_0 Usage: yoda [options] show-sensors @@ -45,6 +51,7 @@ Options: -v, --verbose Output additional information -g, --debug Show debug information on stderr --legacy-690lc Use Asetek 690LC in legacy mode (old Krakens) + --use-device-controller Use the control loop integrated to the device (MPG coreliquid device) --version Display the version number --help Show this message @@ -65,16 +72,18 @@ Copyright Jonas Malaco and contributors SPDX-License-Identifier: GPL-3.0-or-later """ + import ast import logging import math import sys import time +from datetime import datetime from docopt import docopt import liquidctl.cli as _borrow from liquidctl.util import normalize_profile, interpolate_profile -from liquidctl.driver import * +import liquidctl.driver if sys.platform == 'darwin': import re @@ -109,6 +118,7 @@ def read_sensors(device): for label, current, _, _ in li: sensor_name = label.lower().replace(' ', '_') sensors[f'{m}.{sensor_name}'] = current + sensors['cpu_freq'] = psutil.cpu_freq().current return sensors @@ -120,9 +130,12 @@ def show_sensors(device): print('{:<70} {:>6}{}'.format(k, v, '°C')) -def parse_profile(arg, mintemp=0, maxtemp=100, minduty=0, maxduty=100): +def parse_profile(arg, mintemp=0, maxtemp=100, minduty=0, maxduty=100, str_allowed=False): """Parse, validate and normalize a temperature–duty profile. + >>> parse_profile('smart', 0, 60, 25, 100) + 'smart' + >>> parse_profile('(20,30),(30,50),(34,80),(40,90)', 0, 60, 25, 100) [(20, 30), (30, 50), (34, 80), (40, 90), (60, 100)] >>> parse_profile('35', 0, 60, 25, 100) @@ -155,33 +168,86 @@ def parse_profile(arg, mintemp=0, maxtemp=100, minduty=0, maxduty=100): """ try: - val = ast.literal_eval('[' + arg + ']') - if len(val) == 1 and isinstance(val[0], int): - # for arg == '' set fixed duty between mintemp and maxtemp - 1 - val = [(mintemp, val[0]), (maxtemp - 1, val[0])] + if arg in liquidctl.driver.msi.MpgCooler.BUILTIN_MODES.keys() and str_allowed: + return arg + else: + val = ast.literal_eval('[' + arg + ']') + if len(val) == 1 and isinstance(val[0], int): + # for arg == '' set fixed duty between mintemp and maxtemp - 1 + val = [(mintemp, val[0]), (maxtemp - 1, val[0])] except: - raise ValueError('profile must be comma-separated (temperature, duty) tuples') + msg = ( + 'profile must be comma-separated (temperature, duty) tuples or ' + + f'recognised cooler mode string {str(liquidctl.driver.msi.MpgCooler.BUILTIN_MODES.keys())}' + ) + raise ValueError(msg) for step in val: if not isinstance(step, tuple) or len(step) != 2: raise ValueError('profile must be comma-separated (temperature, duty) tuples') temp, duty = step if not isinstance(temp, int) or temp < mintemp or temp > maxtemp: - raise ValueError('temperature must be integer between {} and {}'.format(mintemp, maxtemp)) + raise ValueError( + 'temperature must be integer between {} and {}'.format(mintemp, maxtemp) + ) if not isinstance(duty, int) or duty < minduty or duty > maxduty: raise ValueError('duty must be integer between {} and {}'.format(minduty, maxduty)) return normalize_profile(val, critx=maxtemp) +def auto_control(device, channels, profiles, sensors, update_interval): + assert getattr( + device, 'HAS_AUTOCONTROL', False + ), f'No registered control loop capability for device {device}!' + from datetime import datetime + + device.set_profile(channels, profiles) + + assert all( + s == sensors[0] for s in sensors + ), 'Controlling different channels with different sensors not possible with device control' + sensor = sensors[0] + + LOGGER.info('starting...') + failures = 0 + while True: + try: + sensor_data = read_sensors(device) + temp = sensor_data[sensor] + freq = sensor_data.get('cpu_freq', 0) + + device.set_time(datetime.now()) + device.set_hardware_status( + temp, + cpu_f=freq, + gpu_f=sensor_data.get('gpu_freq', 0), + gpu_U=sensor_data.get('gpu_usage', 0), + ) + failures = 0 + except Exception as err: + failures += 1 + LOGGER.error(err) + if failures >= MAX_FAILURES: + LOGGER.critical('too many failures in a row: %d', failures) + raise + time.sleep(update_interval) + + def control(device, channels, profiles, sensors, update_interval): - LOGGER.info('device: %s on bus %s and address %s', device.description, device.bus, device.address) + LOGGER.info( + 'device: %s on bus %s and address %s', device.description, device.bus, device.address + ) for channel, profile, sensor in zip(channels, profiles, sensors): LOGGER.info('channel: %s following profile %s on %s', channel, str(profile), sensor) averages = [None] * len(channels) cutoff_freq = 1 / update_interval / 10 alpha = 1 - math.exp(-2 * math.pi * cutoff_freq) - LOGGER.info('update interval: %d s; cutoff frequency (low-pass): %.2f Hz; ema alpha: %.2f', - update_interval, cutoff_freq, alpha) + LOGGER.info( + 'update interval: %d s; cutoff frequency (low-pass): %.2f Hz; ema alpha: %.2f', + update_interval, + cutoff_freq, + alpha, + ) try: # more efficient and safer API, but only supported by very few devices @@ -206,9 +272,25 @@ def control(device, channels, profiles, sensors, update_interval): # interpolate on sensor ema and apply corresponding duty duty = interpolate_profile(profile, ema) - LOGGER.info('%s control: lpf(%s) = lpf(%.1f°C) = %.1f°C => duty := %d%%', - channel, sensor, sample, ema, duty) + LOGGER.info( + '%s control: lpf(%s) = lpf(%.1f°C) = %.1f°C => duty := %d%%', + channel, + sensor, + sample, + ema, + duty, + ) apply_duty(channel, duty) + if getattr(device, 'NEEDS_TIME', False): + device.set_time(datetime.now()) + if getattr(device, 'NEEDS_HWSTATUS', False): + device.set_hardware_status( + sensor_data[sensors[0]], + cpu_f=sensor_data.get('cpu_freq', 0), + gpu_f=sensor_data.get('gpu_freq', 0), + gpu_U=sensor_data.get('gpu_usage', 0), + ) + failures = 0 except Exception as err: failures += 1 @@ -222,6 +304,7 @@ def control(device, channels, profiles, sensors, update_interval): if __name__ == '__main__': if len(sys.argv) == 2 and sys.argv[1] == 'doctest': import doctest + doctest.testmod(verbose=True) sys.exit(0) @@ -231,6 +314,7 @@ if __name__ == '__main__': args['--verbose'] = True logging.basicConfig(level=logging.DEBUG, format='[%(levelname)s] %(name)s: %(message)s') import liquidctl.version + LOGGER.debug('yoda v%s', VERSION) LOGGER.debug('liquidctl v%s', liquidctl.version.__version__) elif args['--verbose']: @@ -241,9 +325,11 @@ if __name__ == '__main__': sys.tracebacklimit = 0 frwd = _borrow._make_opts(args) - selected = list(find_liquidctl_devices(**frwd)) + selected = list(liquidctl.driver.find_liquidctl_devices(**frwd)) if len(selected) > 1: - raise SystemExit('too many devices, filter or select one. See liquidctl --help and yoda --help.') + raise SystemExit( + 'too many devices, filter or select one. See liquidctl --help and yoda --help.' + ) elif len(selected) == 0: raise SystemExit('no devices matches available drivers and selection criteria') @@ -253,8 +339,22 @@ if __name__ == '__main__': if args['show-sensors']: show_sensors(device) elif args['control']: - control(device, args[''], list(map(parse_profile, args[''])), - args[''], update_interval=int(args['--interval'])) + if args['--use-device-controller']: + auto_control( + device, + args[''], + list(map(lambda p: parse_profile(p, str_allowed=True), args[''])), + args[''], + update_interval=int(args['--interval']), + ) + else: + control( + device, + args[''], + list(map(parse_profile, args[''])), + args[''], + update_interval=int(args['--interval']), + ) else: raise Exception('nothing to do') except KeyboardInterrupt: diff --git a/liquidctl/driver/__init__.py b/liquidctl/driver/__init__.py index baba50944..90fdce9e1 100644 --- a/liquidctl/driver/__init__.py +++ b/liquidctl/driver/__init__.py @@ -33,6 +33,7 @@ from liquidctl.driver import hydro_platinum from liquidctl.driver import kraken2 from liquidctl.driver import kraken3 +from liquidctl.driver import msi from liquidctl.driver import nzxt_epsu from liquidctl.driver import rgb_fusion2 from liquidctl.driver import smart_device diff --git a/liquidctl/driver/msi.py b/liquidctl/driver/msi.py new file mode 100644 index 000000000..d33b54856 --- /dev/null +++ b/liquidctl/driver/msi.py @@ -0,0 +1,1441 @@ +"""liquidctl drivers for MSI liquid coolers. + +Supported devices: + +- MPG Coreliquid K360 + +Copyright (C) 2021 Andrew Udvare and contributors +SPDX-License-Identifier: GPL-3.0-or-later +""" + +# uses the psf/black style + +from collections import namedtuple +from collections.abc import Sequence +from copy import copy +from enum import Enum, unique +from time import sleep +import logging +import io +from PIL import Image + +from liquidctl.driver.usb import UsbHidDriver +from liquidctl.keyval import RuntimeStorage +from liquidctl.util import RelaxedNamesEnum, clamp + +_LOGGER = logging.getLogger(__name__) + +EXTRA_USAGE_PAGE = 0x0001 +_MAX_DATA_LENGTH = 185 +_PER_LED_LENGTH = 720 +_REPORT_LENGTH = 64 +_MAX_DUTIES = 7 +_RAD_FAN_COUNT = 3 +_CYCLE_NUMBER_STRIPE_TYPE_MAPPING = {0: 41, 1: 52, 2: 63, 3: 20, 4: 30} +_DEFAULT_FEATURE_DATA = [ + 82, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 20, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 20, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 130, + 76, + 10, + 1, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 26, + 255, + 0, + 0, + 168, + 0, + 255, + 0, + 191, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 32, + 255, + 0, + 0, + 40, + 0, + 255, + 0, + 128, + 0, + 0, +] +_DeviceSettings = namedtuple( + "DeviceSettings", + [ + "stripe_or_fan", + "fan_type", + "corsair_device_quantity", + "ll120_outer_individual", + "led_num_jrainbow1", + "led_num_jrainbow2", + "led_num_jcorsair", + ], +) +_BoardSyncSettings = namedtuple( + "BoardSyncSettings", + [ + "onboard_sync", + "combine_jrgb", + "combine_jpipe1", + "combine_jpipe2", + "combine_jrainbow1", + "combine_jrainbow2", + "combine_jcorsair", + ], +) +_StyleSettings = namedtuple( + "StyleSettings", ["lighting_mode", "speed", "brightness", "color_selection"] +) +_ColorSettings = namedtuple("ColorSettings", ["color1", "color2"]) +_FanConfig = namedtuple( + "FanConfig", ["mode", "duty0", "duty1", "duty2", "duty3", "duty4", "duty5", "duty6"] +) +_FanTempConfig = namedtuple( + "FanTempConfig", ["mode", "temp0", "temp1", "temp2", "temp3", "temp4", "temp5", "temp6"] +) + + +@unique +class _OLEDHardwareMonitorOffset(Enum): + CPU_FREQ = 0 + CPU_TEMP = 1 + GPU_MEMORY_FREQ = 2 + GPU_USAGE = 3 + FAN_PUMP = 4 + FAN_RADIATOR = 5 + FAN_CPUMOS = 6 + MAXIMUM = 7 + + +@unique +class _FanMode(RelaxedNamesEnum): + SILENT = 0 + BALANCE = 1 + GAME = 2 + CUSTOMIZE = 3 + DEFAULT = 4 + SMART = 5 + + @classmethod + def _missing_(cls, value): + _LOGGER.debug("falling back to BALANCE for _FanMode(%s)", value) + return _FanMode.BALANCE + + +@unique +class _StripeOrFan(Enum): + STRIPE = 0 + FAN = 1 + + +@unique +class _FanType(Enum): + SP = 0 + HD = 1 + LL = 2 + + +class _LEDArea(Enum): + JCORSAIR = 53 + JCORSAIR_OUTER_LL120 = 0x40 + JPIPE1 = 11 + JPIPE2 = 21 + JRAINBOW1 = 0x1F + JRAINBOW2 = 42 + JRGB1 = 1 + JRGB2 = 174 + ONBOARD_LED_0 = 74 + ONBOARD_LED_1 = 84 + ONBOARD_LED_10 = 174 + ONBOARD_LED_2 = 94 + ONBOARD_LED_3 = 104 + ONBOARD_LED_4 = 114 + ONBOARD_LED_5 = 124 + ONBOARD_LED_6 = 134 + ONBOARD_LED_7 = 144 + ONBOARD_LED_8 = 154 + ONBOARD_LED_9 = 164 + + +_CYCLE_NUMBER_LED_AREA_MAPPING = { + _LEDArea.JPIPE1.value: 20, + _LEDArea.JPIPE2.value: 30, + _LEDArea.JRAINBOW1.value: 41, + _LEDArea.JRAINBOW2.value: 52, + _LEDArea.JCORSAIR.value: 63, +} + + +@unique +class _LightingMode(RelaxedNamesEnum): + BLINK = 19 + BREATHING = 2 + CLOCK = 20 + COLOR_PULSE = 21 + COLOR_RING = 15 + COLOR_RING_DOUBLE_FLASHING = 35 + COLOR_RING_FLASHING = 34 + COLOR_SHIFT = 22 + COLOR_WAVE = 23 + CORSAIR_IQUE = 37 + DISABLE = 0 + DISABLE2 = 33 + DOUBLE_FLASHING = 4 + DOUBLE_METEOR = 17 + ENERGY = 18 + FAN_CONTROL = 32 + FIRE = 38 + FLASHING = 3 + JAZZ = 12 + JRAINBOW = 28 + LAVA = 39 + LIGHTNING = 5 + MARQUEE = 24 + METEOR = 7 + MOVIE = 14 + MSI_MARQUEE = 6 + MSI_RAINBOW = 9 + NO_ANIMATION = 1 + PLANETARY = 16 + PLAY = 13 + POP = 10 + RAINBOW = 25 + RAINBOW_DOUBLE_FLASHING = 30 + RAINBOW_FLASHING = 29 + RAINBOW_WAVE = 26 + RANDOM = 31 + RAP = 11 + STACK = 36 + VISOR = 27 + WATER_DROP = 8 + END = 40 + + +@unique +class _Speed(RelaxedNamesEnum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + + +@unique +class _ColorSelection(Enum): + RAINBOW_COLOR = 0 + USER_DEFINED = 1 + + +@unique +class _JType(Enum): + JRAINBOW = 3 + JCORSAIR = 4 + JONBOARD = 5 + + +@unique +class _UploadType(Enum): + BANNER = 0 + GIF = 1 + + +@unique +class _ScreenMode(Enum): + HARDWARE = 0 + IMAGE = 1 + BANNER = 3 + CLOCK = 4 + SETTINGS = 5 + DISABLED = 6 + + +class MpgCooler(UsbHidDriver): + _COLOR_MODES = { + "blink": _LightingMode.BLINK, + "breathing": _LightingMode.BREATHING, + "clock": _LightingMode.CLOCK, + "color pulse": _LightingMode.COLOR_PULSE, + "color ring": _LightingMode.COLOR_RING, + "color ring double flashing": _LightingMode.COLOR_RING_DOUBLE_FLASHING, + "color ring flashing": _LightingMode.COLOR_RING_FLASHING, + "color shift": _LightingMode.COLOR_SHIFT, + "color wave": _LightingMode.COLOR_WAVE, + "corsair ique": _LightingMode.CORSAIR_IQUE, + "disable": _LightingMode.DISABLE, + "disable2": _LightingMode.DISABLE2, + "double flashing": _LightingMode.DOUBLE_FLASHING, + "double meteor": _LightingMode.DOUBLE_METEOR, + "energy": _LightingMode.ENERGY, + "fan control": _LightingMode.FAN_CONTROL, + "fire": _LightingMode.FIRE, + "flashing": _LightingMode.FLASHING, + "jazz": _LightingMode.JAZZ, + "jrainbow": _LightingMode.JRAINBOW, + "lava": _LightingMode.LAVA, + "lightning": _LightingMode.LIGHTNING, + "marquee": _LightingMode.MARQUEE, + "meteor": _LightingMode.METEOR, + "movie": _LightingMode.MOVIE, + "msi marquee": _LightingMode.MSI_MARQUEE, + "msi rainbow": _LightingMode.MSI_RAINBOW, + "steady": _LightingMode.NO_ANIMATION, + "planetary": _LightingMode.PLANETARY, + "play": _LightingMode.PLAY, + "pop": _LightingMode.POP, + "rainbow": _LightingMode.RAINBOW, + "rainbow double flashing": _LightingMode.RAINBOW_DOUBLE_FLASHING, + "rainbow flashing": _LightingMode.RAINBOW_FLASHING, + "rainbow wave": _LightingMode.RAINBOW_WAVE, + "random": _LightingMode.RANDOM, + "rap": _LightingMode.RAP, + "stack": _LightingMode.STACK, + "visor": _LightingMode.VISOR, + "water drop": _LightingMode.WATER_DROP, + "end": _LightingMode.END, + } + BUILTIN_MODES = { + "silent": _FanMode.SILENT.value, + "balanced": _FanMode.BALANCE.value, + "game": _FanMode.GAME.value, + "default": _FanMode.DEFAULT.value, + "smart": _FanMode.SMART.value, + } + SCREEN_MODES = { + "hardware": _ScreenMode.HARDWARE, + "image": _ScreenMode.IMAGE, + "banner": _ScreenMode.BANNER, + "clock": _ScreenMode.CLOCK, + "settings": _ScreenMode.SETTINGS, + "disable": _ScreenMode.DISABLED, + } + HWMONITORDISPLAY = { + "cpu_freq": _OLEDHardwareMonitorOffset.CPU_FREQ, + "cpu_temp": _OLEDHardwareMonitorOffset.CPU_TEMP, + "gpu_freq": _OLEDHardwareMonitorOffset.GPU_MEMORY_FREQ, + "gpu_usage": _OLEDHardwareMonitorOffset.GPU_USAGE, + "fan_pump": _OLEDHardwareMonitorOffset.FAN_PUMP, + "fan_radiator": _OLEDHardwareMonitorOffset.FAN_RADIATOR, + "fan_cpumos": _OLEDHardwareMonitorOffset.FAN_CPUMOS, + } + _MATCHES = [ + (0x0DB0, 0xB130, "MSI MPG Coreliquid K360", {"fan_count": 5}), + (0x0DB0, 0x6a05, "MSI Coreliquid S360", {"fan_count": 5}), + (0x0DB0, 0xCA00, "Unknown", {}), + (0x0DB0, 0xCA02, "Unknown", {}), + ] + HAS_AUTOCONTROL = True + + def __init__(self, device, description, **kwargs): + super().__init__(device, description, **kwargs) + self._feature_data_per_led = bytearray(_PER_LED_LENGTH + 5) + self._bytearray_oled_hardware_monitor_data = bytearray(_REPORT_LENGTH) + self._per_led_rgb_jonboard = bytearray(_PER_LED_LENGTH) + self._per_led_rgb_jrainbow1 = bytearray(_PER_LED_LENGTH) + self._per_led_rgb_jrainbow2 = bytearray(_PER_LED_LENGTH) + self._per_led_rgb_jcorsair = bytearray(_PER_LED_LENGTH) + self._fan_count = kwargs.pop("fan_count", 5) + # the following fields are only initialized in connect() + self._data = None + self._feature_data = None + + @classmethod + def probe(cls, handle, **kwargs): + """Probe `handle` and yield corresponding driver instances. + + These devices have multiple top-level HID usages, and HidapiDevice + handles matching other usages have to be ignored. + """ + + # if usage_page/usage are not available due to hidapi limitations + # (version, platform or backend), they are unfortunately left + # uninitialized; because of this, we explicitly exclude the undesired + # usage_page, and assume in all other cases that we either + # have the desired usage page, or that on that system a + # single handle is returned for that device interface (see: 259) + + if handle.hidinfo["usage_page"] == EXTRA_USAGE_PAGE: + return + yield from super().probe(handle, **kwargs) + + def connect(self, **kwargs): + ret = super().connect(**kwargs) + self._data = kwargs.pop( + "runtime_storage", + RuntimeStorage( + key_prefixes=[ + f"vid{self.vendor_id:04x}_pid{self.product_id:04x}", + f"serial{self.serial_number}", + ] + ), + ) + self._feature_data = self.device.get_feature_report(0x52, _MAX_DATA_LENGTH) + self._fan_cfg = self.get_fan_config() + self._fan_temp_cfg = self.get_fan_temp_config() + + return ret + + def initialize(self, **kwargs): + pump_mode = kwargs.pop("pump_mode", "balanced") + direction = kwargs.pop("direction", "default") + if pump_mode == "balanced": + pump_mode = "balance" + pump_mode_int = _FanMode[pump_mode].value + self._data.store("pump_mode", pump_mode_int) + dir_int = 0 + if direction not in ("default", "top", "bottom", "left", "right", "0", "1", "2", "3"): + _LOGGER.warning( + "Unknown direction value. Correct values are 0-3 or top, " + "bottom, left, right, default." + ) + if direction in ("1", "top"): + dir_int = 1 + elif direction in ("2", "left"): + dir_int = 2 + elif direction in ("3", "bottom"): + dir_int = 3 + self._data.store("direction", dir_int) + self.set_oled_brightness_and_direction(100, dir_int) + if pump_mode_int == _FanMode.GAME.value: + self.switch_to_game_mode() + elif pump_mode_int == _FanMode.BALANCE.value: + self.switch_to_balance_mode() + elif pump_mode_int == _FanMode.DEFAULT.value: + self.switch_to_default_mode() + elif pump_mode_int == _FanMode.SILENT.value: + self.switch_to_silent_mode() + elif pump_mode_int == _FanMode.SMART.value: + self.switch_to_smart_mode() + + def get_status(self, **kwargs): + self._write((0x31,)) + array = self._read() + assert array[1] == 0x31, "Unexpected value in response buffer" + return [ + ("Fan 1 speed", array[2] + (array[3] << 8), "rpm"), + ("Fan 1 duty", array[0x16] + (array[0x17] << 8), "%"), + ("Fan 2 speed", array[4] + (array[5] << 8), "rpm"), + ("Fan 2 duty", array[0x18] + (array[0x19] << 8), "%"), + ("Fan 3 speed", array[6] + (array[7] << 8), "rpm"), + ("Fan 3 duty", array[0x1A] + (array[0x1B] << 8), "%"), + ("Water block speed", array[8] + (array[9] << 8), "rpm"), + ("Water block duty", array[0x1C] + (array[0x1D] << 8), "%"), + ("Pump speed", array[0xA] + (array[0xB] << 8), "rpm"), + ("Pump duty", array[0x1E] + (array[0x1F] << 8), "%"), + ## ('Temperature inlet', array[12] + (array[13] << 8), '°C'), + ## ('Temperature outlet', array[14] + (array[15] << 8), '°C'), + ## ('Temperature sensor 1', array[16] + (array[17] << 8), '°C'), + ## ('Temperature sensor 2', array[18] + (array[19] << 8), '°C'), + ] + + def set_time(self, time): + return self.set_oled_clock(time) + + def set_hardware_status(self, T, cpu_f=0, gpu_f=0, gpu_U=0, **kwargs): + self.set_oled_show_cpu_status(cpu_f, T) + self.set_oled_gpu_status(gpu_f, gpu_U) + + def get_fan_config(self): + self._write((0x32,)) + buf = self._read() + assert buf[1] == 0x32, "Unexpected value in returned list" + ret = [] + for mode_index in (2, 10, 18, 26, 34): + ret.append(_FanConfig(*buf[mode_index : mode_index + 8])) + return ret + + def set_fan_config(self, configs): + buf = bytearray(_REPORT_LENGTH - 1) + buf[0] = 0x40 + for config, offset in zip(configs, (1, 9, 17, 25, 33)): + buf[offset] = config.mode + buf[offset + 1] = config.duty0 + buf[offset + 2] = config.duty1 + buf[offset + 3] = config.duty2 + buf[offset + 4] = config.duty3 + buf[offset + 5] = config.duty4 + buf[offset + 6] = config.duty5 + buf[offset + 7] = config.duty6 + return self._write(buf) + + def get_fan_temp_config(self): + self._write((0x33,)) + buf = self._read() + assert buf[1] == 0x32, "Unexpected value in returned list" + ret = [] + for mode_index in (2, 10, 18, 26, 34): + ret.append(_FanTempConfig(*buf[mode_index : mode_index + 8])) + return ret + + def set_fan_temp_config(self, configs): + buf = bytearray(_REPORT_LENGTH) + buf[0] = 0x41 + for config, offset in zip(configs, (1, 9, 17, 25, 33)): + buf[offset] = config.mode + buf[offset + 1] = config.temp0 + buf[offset + 2] = config.temp1 + buf[offset + 3] = config.temp2 + buf[offset + 4] = config.temp3 + buf[offset + 5] = config.temp4 + buf[offset + 6] = config.temp5 + buf[offset + 7] = config.temp6 + return self._write(buf) + + def set_profile(self, channels, profiles): + fan_cfg = self.get_fan_config() + fan_temp_cfg = self.get_fan_temp_config() + channel_idx = [self.parse_channel(ch) for ch in channels] + + for idx, prof in zip(channel_idx, profiles): + if type(prof) == str: + fanmode = _FanConfig(self.BUILTIN_MODES[prof], 0, 0, 0, 0, 0, 0, 0) + tempmode = _FanTempConfig(self.BUILTIN_MODES[prof], 0, 0, 0, 0, 0, 0, 0) + else: + duties, temps = map(self.clamp_and_pad, zip(*prof)) + fanmode = _FanConfig(_FanMode.CUSTOMIZE.value, *duties) + tempmode = _FanTempConfig(_FanMode.CUSTOMIZE.value, *temps) + for i in idx: + fan_cfg[i] = fanmode + fan_temp_cfg[i] = tempmode + + self.set_fan_config(fan_cfg) + self.set_fan_temp_config(fan_temp_cfg) + + def parse_channel(self, channel): + if channel == "pump": + return [4] + elif channel == "fans": + return range(_RAD_FAN_COUNT) + elif channel == "waterblock fan": + return [3] + elif channel[:3] == "fan" and (int(channel[3:]) in range(_RAD_FAN_COUNT)): + return [int(channel[3:])] + else: + raise ValueError( + 'unknown channel, should be "fans", "fan1", "fan2", "fan3", "waterblock fan" or "pump".' + ) + + @staticmethod + def clamp_and_pad(values): + return ([clamp(v, 0, 100) for v in values] + [0] * _MAX_DUTIES)[:_MAX_DUTIES] + + def set_speed_profile(self, channel, profile, **opts): + """ + NOTE: The device will not keep updating its duty cycles + automatically after this function is called. The device + manages duties according to the previous temperature + sent to it via device.set_oled_show_cpu_status() + """ + + duties_temps = list(zip(*profile)) + duties, temps = tuple(self.clamp_and_pad(v) for v in duties_temps) + + for i in self.parse_channel(channel): + self._fan_cfg[i] = _FanConfig(_FanMode.CUSTOMIZE.value, *duties) + self._fan_temp_cfg[i] = _FanTempConfig(_FanMode.CUSTOMIZE.value, *temps) + + self.set_fan_config(self._fan_cfg) + self.set_fan_temp_config(self._fan_temp_cfg) + _LOGGER.warning( + "Duty profiles on this device require continuous communication! " + "To keep automatically updating duties according to this profile, " + "you can use the extra/coreliquid_ctl script that was created " + "specifically for that purpose." + ) + + # for safety, set the initial temperature point + self.set_oled_show_cpu_status(0, 100) + + def set_fixed_speed(self, channel, duty, **opts): + channel_nums = self.parse_channel(channel) + + for i in channel_nums: + self._fan_cfg[i] = _FanConfig(_FanMode.CUSTOMIZE.value, *([duty] * _MAX_DUTIES)) + self._fan_temp_cfg[i] = _FanTempConfig(_FanMode.CUSTOMIZE.value, 0, 0, 0, 0, 0, 0, 0) + + self.set_fan_config(self._fan_cfg) + self.set_fan_temp_config(self._fan_temp_cfg) + + def set_color(self, _, mode, colors, speed=1, brightness=10, color_selection=1, **opts): + colors = list(colors) + if not colors: + color_selection = 0 + else: + if len(colors) == 1: + colors.append((0, 0, 0)) + self.set_color_setting(_LEDArea.JRAINBOW1.value, *colors[0], *colors[1]) + + mode = self._COLOR_MODES[mode].value + self.set_style_setting( + _LEDArea.JRAINBOW1.value, mode, int(speed), brightness, color_selection + ) + self.set_send_led_setting(1) + + def get_current_model_index(self): + self._write((0xB1,), 0xCC, prefix=1) + return self._read()[2] + + def set_screen(self, channel, mode, value, **kwargs): + assert channel.lower() == "lcd" + try: + mode = self.SCREEN_MODES[mode] + except KeyError as e: + raise Exception( + f"Unknown screen mode! Should be one of: {self.SCREEN_MODES.keys()}" + ) from e + + if mode == _ScreenMode.HARDWARE: + show_idx = [self.HWMONITORDISPLAY[x].value for x in value.split(";")] + show_area = [i in show_idx for i in range(_OLEDHardwareMonitorOffset.MAXIMUM.value + 1)] + self.set_oled_show_hardware_monitor(show_area) + if mode == _ScreenMode.IMAGE: + values = value.split(";") + imgtype, idx = map(int, values[:2]) + if len(values) > 2: + assert imgtype == 1, "Cannot override default images (image type 0)" + file = values[2] + bmp_img = self._prepare_bmp(file) + self.set_oled_upload_gif(bmp_img, idx) + self.set_oled_show_profile(imgtype, idx) + elif mode == _ScreenMode.BANNER: + opts = value.split(";") + if len(opts) == 3: + banner_type, save_slot = opts[:2] + message = opts[2] + self.set_oled_user_message(message) + self.set_oled_show_banner(banner_type=int(banner_type), bmp_no=int(save_slot)) + elif len(opts) == 4: + banner_type, save_slot, message, image_file = opts + banner_type, save_slot = map(int, (banner_type, save_slot)) + img = self._prepare_bmp(image_file) + assert save_slot >= 4, ( + "Cannot overwrite preset banner images, " + "please use save slots starting from 4 for your uploaded files" + ) + print("here") + self.set_oled_upload_banner(img, banner_no=save_slot) + self.set_oled_user_message(message) + self.set_oled_show_banner(banner_type=banner_type, bmp_no=save_slot) + elif mode == _ScreenMode.CLOCK: + style = int(value) + self.set_oled_show_clock(style) + elif mode == _ScreenMode.SETTINGS: + brightness, direction = [int(x) for x in value.split(";")] + self.set_oled_brightness_and_direction(brightness=brightness, direction=direction) + elif mode == _ScreenMode.DISABLED: + self.set_oled_show_disable() + + def _prepare_bmp(self, path): + end_w, end_h = 240, 320 + img = Image.open(path) + w, h = img.size + wrat = end_w / w + hrat = end_h / h + ratio = wrat if wrat > hrat else hrat + img = img.resize((int(ratio * w), int(ratio * h))) + w, h = img.size + x_start = int((w - end_w) / 2) + y_start = int((h - end_h) / 2) + img = img.crop((x_start, y_start, x_start + end_w, y_start + end_h)) + + img = img.convert("RGB") + img_bytes = io.BytesIO() + img.save(img_bytes, format="BMP") + return img_bytes + + def get_firmware_version_aprom(self): + self._write((0xB0,), 0xCC, prefix=1) + ret = self._read() + return ( + ret[2] >> 4, # high + ret[2] & 0xF, # low + ) + + def get_firmware_version_ldrom(self): + self._write((0xB6,), 0xCC, prefix=1) + ret = self._read() + return ( + ret[2] >> 4, # high + ret[2] & 0xF, # low + ) + + def get_firmware_checksum_aprom(self): + self._write((0xB0,), 0xCC, prefix=1) + ret = self._read() + return ( + ret[8], # high + ret[9], # low + ) + + def get_firmware_checksum_ldrom(self): + self._write((0xB4,), 0xCC, prefix=1) + ret = self._read() + return ( + ret[8], # high + ret[9], # low + ) + + def get_all_hardware_monitor(self): + self.device.clear_enqueued_reports() + return self.device.get_feature_report(0xD0, _REPORT_LENGTH) + + def set_buzzer(self, type, frequency=650): + return self._write( + (0xC2, 0, 0, 0, 0, 0, type, frequency & 0xFF, (frequency >> 8) & 0xFF), prefix=1 + ) + + def set_led_global(self, on_off): + return self._write((0xBB, 0, 0, 0, 0, int(on_off)), prefix=1) + + def get_led_global(self): + self._write((0xBA,), 0xCC, prefix=1) + ret = self._read() + ok = len(ret) == _REPORT_LENGTH + for j, _ in enumerate(ret): + if ( + (j == 0 and ret[j] != 1) + or (j == 1 and ret[j] != 90) + or (j == 6 and ret[j] not in (0, 1)) + ): + ok = False + elif ret[j] != 0xCC: + ok = False + return ok and ret[6] == 1 + + def get_led_pe0(self): + self._write((0xA0, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 1, 0xF2), 0xCC, prefix=0xFA) + ret = self._read() + ok = True + if ( + ret[0] != 250 + or ret[1] != 160 + or ret[2] != 205 + or ret[9] != 1 + or ret[10] != 242 + or ret[11] not in (0, 1) + ): + ok = 0 + for j, n in enumerate(ret, start=23): + if j > 29: + break + if n != 0: + ok = False + for j, n in enumerate(ret, start=30): + if n != 0xCC: + ok = False + return ok and ret[11] == 1 + + def get_device_settings(self): + return _DeviceSettings( + self._feature_data[61] & 1, + (self._feature_data[61] & 0xE) >> 1, + (self._feature_data[62] & 0xFC) >> 2, + self._feature_data[72] & 1, + self._feature_data[41], + self._feature_data[52], + self._feature_data[63], + ) + + def get_board_sync_settings(self): + return _BoardSyncSettings( + (self._feature_data[82] & 1) == 1, + (self._feature_data[78] & 0x80) >> 7 == 1, + ((self._feature_data[82] >> 4) & 1) == 1, + ((self._feature_data[82] >> 5) & 1) == 1, + ((self._feature_data[82] >> 1) & 1) == 1, + ((self._feature_data[82] >> 2) & 1) == 1, + ((self._feature_data[82] >> 3) & 1) == 1, + ) + + def get_style_settings(self, led_area): + return _StyleSettings( + self._feature_data[led_area], + (self._feature_data[led_area + 4] & 3), + (self._feature_data[led_area + 4] >> 2) & 0x1F, + ((self._feature_data[led_area + 8] & 0x80) >> 7), + ) + + def get_color_settings(self, led_area): + return _ColorSettings( + self._feature_data[led_area + 1 : led_area + 4], + self._feature_data[led_area + 5 : led_area + 8], + ) + + def get_current_led_setting(self): + self._feature_data = self.device.get_feature_report(self._feature_data[0], _MAX_DATA_LENGTH) + self._feature_data[62] = clamp(self._feature_data[62] - 4, 0, 255) + return self._feature_data + + def get_all_board(self): + return self.device.get_feature_report(0x52, _MAX_DATA_LENGTH) + + def get_oled_firmware_version(self): + self._write((0xF1,)) + return self._read()[2] + + def get_oled_gif_checksum(self): + self._write((0xC2,)) + ret = self._read() + return ( + ret[3], # high + ret[2], # low + ) + + def get_oled_banner_checksum(self): + self._write((0xD2,)) + buf = self._read() + return ( + buf[3], # high + buf[2], # low + ) + + def get_oled_m481checksum(self): + self._write((0xF1,)) + ret = self._read() + return ( + ret[3], # high + ret[2], # low + ) + + def set_volume(self, main, left, right): + return self._write( + (0xC0, clamp(main, 0, 100), clamp(left, 0, 100), clamp(right, 0, 100)), 0xCC, prefix=1 + ) + + def set_oled_cpu_message(self, message): + return self._write([0x90] + list(message[:60].encode("ascii", "ignore"))) + + def set_oled_show_hardware_monitor(self, show_area, radiator_fan_smart_mode_on_off=True): + """ + + Parameters + ---------- + show_area: + Array[bool] Indicates which hardware features will be presented on the screen, + from CPU_FREQ, CPU_TEMP, GPU_MEMORY_FREQ, GPU_USAGE, FAN_PUMP, FAN_RADIATOR, FAN_CPUMOS + """ + if len(show_area) < 7: + return False + buf = bytearray(_REPORT_LENGTH) + buf[0] = 0xD0 + buf[1] = 0x71 + for item in iter(_OLEDHardwareMonitorOffset): + print(item.value) + if item.value != _OLEDHardwareMonitorOffset.MAXIMUM and show_area[item.value]: + buf[item.value + 2] = 1 + if show_area[5]: + buf[9] = 3 if radiator_fan_smart_mode_on_off else 1 + return self._write(buf[1:]) + + def set_return_to_default(self): + """--reset-all""" + self._feature_data = copy(_DEFAULT_FEATURE_DATA) + self._feature_data[184] = 1 + return self._set_all_board(self._feature_data) + + def _set_all_board(self, data): + if data[41] > 200: + data[41] = 100 + if data[52] > 240: + data[52] = 100 + b = (data[62] & 0xFC) >> 2 + if ((b + 1) * data[63]) > 240: + b = 5 + data[62] = b << 2 + data[63] = 12 + if len(data) < 185 or data[0] != 82: + return False + return bool(self.device.send_feature_report(data)) + + def set_cycle_number(self, stripe_type, cycle_num): + """Cycle number should NOT be clamped.""" + if stripe_type in _CYCLE_NUMBER_STRIPE_TYPE_MAPPING: + self._feature_data[_CYCLE_NUMBER_STRIPE_TYPE_MAPPING[stripe_type]] = cycle_num + + def set_cycle_number_by_led_area(self, led_area, cycle_num): + if led_area in _CYCLE_NUMBER_LED_AREA_MAPPING: + self._feature_data[_CYCLE_NUMBER_LED_AREA_MAPPING[led_area]] = cycle_num + + def set_device_setting( + self, stripe_or_fan, fan_type, corsair_device_qty, ll120_outer_individual + ): + corsair_device_qty = clamp(corsair_device_qty, 0, 63) + ll120_outer_individual = clamp(int(ll120_outer_individual), 0, 1) + self._feature_data[61] = (self._feature_data[61] & 0x80) | (fan_type << 1) | stripe_or_fan + self._feature_data[62] = corsair_device_qty << 2 + self._feature_data[72] = self._feature_data[72] | ll120_outer_individual + + def set_board_sync_setting( + self, + onboard_sync, + combine_jrgb, + combine_jpipe1, + combine_jpipe2, + combine_jrainbow1, + combine_jrainbow2, + combine_jcorsair, + ): + self._feature_data[82] |= clamp(int(onboard_sync), 0, 1) + self._feature_data[78] |= 0b10000000 if combine_jrgb else 0 + self._feature_data[82] |= 0b00010000 if combine_jpipe1 else 0 + self._feature_data[82] |= 0b00100000 if combine_jpipe2 else 0 + self._feature_data[82] |= 0b00000010 if combine_jrainbow1 else 0 + self._feature_data[82] |= 0b00000100 if combine_jrainbow2 else 0 + self._feature_data[82] |= 0b00001000 if combine_jcorsair else 0 + + def set_style_setting(self, led_area, lighting_mode, speed, brightness, color_selection): + """ + --led-area + --lighting-mode + --speed + --brightness + --color-selection + """ + lighting_mode = clamp(lighting_mode, 0, 40) + speed = clamp(speed, 0, 2) + brightness = clamp(brightness, 0, 10) + color_selection = clamp(color_selection, 0, 1) + self._feature_data[led_area] = lighting_mode + self._feature_data[led_area + 4] = ( + (self._feature_data[led_area + 4] & 0x80) | (brightness << 2) | speed + ) + self._feature_data[led_area + 8] = 0b10000000 if color_selection else 0 + + def set_color_setting( + self, led_area, color1_r, color1_g, color1_b, color2_r, color2_g, color2_b + ): + """ + --led-area + --color1 + --color2 + """ + + self._feature_data[led_area + 1] = clamp(color1_r, 0, 255) + self._feature_data[led_area + 2] = clamp(color1_g, 0, 255) + self._feature_data[led_area + 3] = clamp(color1_b, 0, 255) + self._feature_data[led_area + 4] = clamp(led_area, 0, 174) + self._feature_data[led_area + 5] = clamp(color2_r, 0, 255) + self._feature_data[led_area + 6] = clamp(color2_g, 0, 255) + self._feature_data[led_area + 7] = clamp(color2_b, 0, 255) + + def set_send_led_setting(self, save): + """applies changes, persists to device with save option""" + self._feature_data[184] = int(bool(save)) + return self._set_all_board(self._feature_data) + + def set_direction_setting_b931_only( + self, board_sync, jrainbow1, jrainbow2, jrainbow3, jrainbow4, jrainbow5 + ): + """--b931-direction ?""" + self._feature_data[83] |= int(bool(board_sync)) + self._feature_data[39] |= int(bool(jrainbow1)) + self._feature_data[61] |= int(bool(jrainbow2)) + self._feature_data[50] |= int(bool(jrainbow3)) + self._feature_data[19] |= int(bool(jrainbow4)) + self._feature_data[29] |= int(bool(jrainbow5)) + + def set_oled_user_message(self, message): + """--message""" + return self._write([0x93] + list((message[:61] + " ").encode("ascii", "ignore"))) + + def set_oled_show_gameboot(self, selection, message): + """ + --selection + --message + """ + return self._write([0x73, selection] + list(message[:60].encode("ascii", "ignore"))) + + def set_oled_show_profile(self, profile_type=0, gif_no=0): + """ + --profile-type + --gif-number + """ + data = [0x70, 0, gif_no] + self._write(data) + data[1] = clamp(profile_type, 0, 1) + return self._write(data) + + def set_oled_show_banner(self, banner_type=0, bmp_no=0): + """ + --banner-type + --bmp-number + """ + data = [0x79, banner_type, bmp_no] + return self._write(data) + + def set_oled_show_disable(self): + """--disable-oled""" + return self._write((0x7F,)) + + def _set_oled_upload(self, type, bytes, type_num=0): + print(type == _UploadType.GIF) + start_cmd = 0xC0 if type == _UploadType.GIF else 0xD0 + content = bytes.getbuffer() + l = len(content) + if l > (2**20): + raise ValueError("Too big") + self._write( + (start_cmd, l & 0xFF, (l >> 8) & 0xFF, (l >> 16) & 0xFF, (l >> 24) & 0xFF, type_num) + ) + sleep(((l / 4096 + 3) * 100) / 1000) + sleep(1) + n = 0 + while n < l: + array = [start_cmd + 1] + o = clamp(l - n, 0, 60) + for k in range(0, o): + array.append(content[n + k]) + n += o + self._write(array) + high, low = self.get_oled_gif_checksum() + if low != (l & 0xFF) or high != ((l >> 8) & 0xFF): + _LOGGER.debug( + f"image checksums: high {high} vs {l & 0xFF}, low {low} vs {(l >> 8) & 0xFF}." + ) + + def set_oled_upload_gif(self, bytes, gif_no=0): + """ + --upload-gif-file + --upload-gif-number + """ + self._set_oled_upload(_UploadType.GIF, bytes, gif_no) + + def set_oled_upload_banner(self, bytes, banner_no=4): + """Default is 4 to not overwrite the default banners. + + --upload-banner-file + --upload-banner-number + """ + self._set_oled_upload(_UploadType.BANNER, bytes, banner_no) + + def set_oled_clock(self, time): + """ + Sends the specified time to the device. + """ + return self._write( + ( + 0x83, + time.year % 100, + time.month, + time.day, + time.weekday(), + time.hour, + time.minute, + time.second, + ) + ) + + def set_oled_show_clock(self, style): + """--set-oled-mode clock""" + return self._write((0x7A, clamp(style, 0, 2))) + + def set_oled_show_cpu_status(self, freq, temp): + """--update-cpu-status""" + freq = clamp(int(freq), 0, 65536) + temp = clamp(int(temp), 0, 65536) + return self._write( + ( + 0x85, + freq & 0xFF, + (freq >> 8) & 0xFF, + temp & 0xFF, + (temp >> 8) & 0xFF, + ) + ) + + @staticmethod + def _make_buffer(array, fill=0, total_size=_REPORT_LENGTH, prefix=0xD0): + return bytearray([prefix] + list(array) + ((total_size - (len(array) + 1)) * [fill])) + + def _write(self, array, fill=0, total_size=_REPORT_LENGTH, prefix=0xD0): + self.device.clear_enqueued_reports() + return self.device.write(self._make_buffer(array, fill, total_size, prefix)) + + def _read(self, size=_REPORT_LENGTH): + return bytearray(self.device.read(size)) + + def set_oled_gpu_status(self, mem_freq, usage): + """ + --set-gpu-memory-frequency + --set-gpu-usage + """ + mem_freq = clamp(int(mem_freq), 0, 65536) + usage = clamp(int(usage), 0, 65536) + return self._write( + ( + 0x86, + mem_freq & 0xFF, + (mem_freq >> 8) & 0xFF, + usage & 0xFF, + (usage >> 8) & 0xFF, + ) + ) + + def set_oled_brightness_and_direction(self, brightness=100, direction=0): + """ + --set-oled-brightness + --direction + """ + return self._write((0x7E, clamp(brightness, 0, 100), clamp(direction, 0, 3))) + + def set_per_led_720byte(self, jtype, area, rgb_data): + b = jtype + if self.product_id != 0x7B10 and self.product_id != 0x7C34: + b += 1 + if len(rgb_data) < 3: + rgb_data = bytearray(3) + rgb_data = rgb_data[:_PER_LED_LENGTH] + self._feature_data_per_led = self._make_buffer( + [0x37, b, area] + list(rgb_data), total_size=_PER_LED_LENGTH + 5, prefix=0x53 + ) + return self.device.send_feature_report(self._feature_data_per_led) + + def set_per_led_index(self, jtype, area, index_and_rgb, show=True, led_count=0): + """ + --set-per-led-index + --jtype INT + --area INT + --index-and-rgb hex string? + --show (or not passed) + --maximum-leds INT + """ + array = bytearray(1) + + def rank_check(array): + return ( + isinstance(array, Sequence) + and all(isinstance(x, Sequence) for x in array) + and all(not isinstance(xa, Sequence) for x in array for xa in x) + ) + + if len(index_and_rgb[1]) < 4 or len(index_and_rgb[0]) < 1 or not rank_check(index_and_rgb): + raise ValueError("index_and_rgb should be a 2-dimensional list") + if jtype == _JType.JONBOARD.value: + array = self._per_led_rgb_jonboard + elif jtype == _JType.JRAINBOW.value: + area = clamp(area, 0, 1) + array = self._per_led_rgb_jrainbow2 if area == 1 else self._per_led_rgb_jrainbow1 + elif jtype == _JType.JCORSAIR.value: + array = self._per_led_rgb_jcorsair + end_index = led_count if led_count > 0 else len(index_and_rgb) + for i in range(end_index): + if index_and_rgb[i][0] * 3 + 2 < len(array): + array[index_and_rgb[i][0] * 3] = index_and_rgb[i][1] + array[index_and_rgb[i][0] * 3 + 1] = index_and_rgb[i][2] + array[index_and_rgb[i][0] * 3 + 2] = index_and_rgb[i][3] + if show: + self.set_per_led_720byte(jtype, area, array) + + def set_switch_to_per_led_mode(self, jtype, area, show=False): + """ + --switch-to-per-led-mode + --jtype INT + --area INT + --show (or not passed) + """ + if jtype == _JType.JONBOARD.value: + self.set_style_setting( + _LEDArea.ONBOARD_LED_0.value, + _LightingMode.CORSAIR_IQUE.value, + _Speed.MEDIUM.value, + 10, + _ColorSelection.USER_DEFINED.value, + ) + self._per_led_rgb_jonboard = bytearray(_PER_LED_LENGTH) + elif jtype == _JType.JRAINBOW.value: + if area == 0: + self._per_led_rgb_jrainbow1 = bytearray(_PER_LED_LENGTH) + self.set_cycle_number(0, 200) + self.set_style_setting( + _LEDArea.JRAINBOW1.value, + _LightingMode.CORSAIR_IQUE.value, + _Speed.MEDIUM.value, + 10, + _ColorSelection.USER_DEFINED.value, + ) + elif area == 1: + self._per_led_rgb_jrainbow2 = bytearray(_PER_LED_LENGTH) + self.set_cycle_number(1, 240) + self.set_style_setting( + _LEDArea.JRAINBOW2.value, + _LightingMode.CORSAIR_IQUE.value, + _Speed.MEDIUM.value, + 10, + _ColorSelection.USER_DEFINED.value, + ) + elif jtype == _JType.JCORSAIR.value: + self._per_led_rgb_jcorsair = bytearray(_PER_LED_LENGTH) + self.set_style_setting( + _LEDArea.JCORSAIR.value, + _LightingMode.CORSAIR_IQUE.value, + _Speed.MEDIUM.value, + 10, + _ColorSelection.USER_DEFINED.value, + ) + if ((self._feature_data[61] & 0xE) >> 1) == 0 and (self._feature_data[61] & 1) == 1: + self.set_device_setting(_StripeOrFan.FAN.value, _FanType.SP.value, 5, 0) + else: + self.set_cycle_number(2, 240) + self.set_device_setting(_StripeOrFan.STRIPE.value, _FanType.HD.value, 0, 0) + self.set_board_sync_setting(True, True, True, True, False, False, False) + if show: + self.set_send_led_setting(False) + self.set_clear_per_led(jtype, area) + + def set_clear_per_led(self, jtype, area): + """ + --clear-per-led + --jtype + --area + """ + return self.set_per_led_720byte(jtype, area, bytearray(3)) + + def _set_all(self, which, r=0, g=0, b=0): + self.set_board_sync_setting(True, True, True, True, True, True, True) + self.set_style_setting(_LEDArea.ONBOARD_LED_0.value, which, 1, 10, 1) + self.set_color_setting(_LEDArea.ONBOARD_LED_0.value, r, g, b, r, g, b) + self.set_send_led_setting(False) + + def set_all_disable(self): + """--led-disable""" + self._set_all(_LightingMode.DISABLE.value) + + def set_all_static(self, r, g, b): + """--led-all-static""" + self._set_all(_LightingMode.NO_ANIMATION.value, r, g, b) + + def set_all_flashing(self, r, g, b): + """--led-all-flashing""" + self._set_all(_LightingMode.FLASHING.value, r, g, b) + + def set_all_breathing(self, r, g, b): + """--led-all-breathing""" + self._set_all(_LightingMode.BREATHING.value, r, g, b) + + def set_all_rainbow_wave(self): + """--led-all-rainbow-wave""" + self._set_all(_LightingMode.RAINBOW_WAVE.value) + + def switch_to_balance_mode(self): + self.set_fan_config( + [_FanConfig(_FanMode.BALANCE.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + self.set_fan_temp_config( + [_FanTempConfig(_FanMode.BALANCE.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + + def switch_to_game_mode(self): + self.set_fan_config( + [_FanConfig(_FanMode.GAME.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + self.set_fan_temp_config( + [_FanTempConfig(_FanMode.GAME.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + + def switch_to_silent_mode(self): + self.set_fan_config( + [_FanConfig(_FanMode.SILENT.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + self.set_fan_temp_config( + [_FanTempConfig(_FanMode.SILENT.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + + def switch_to_smart_mode(self): + self.set_fan_config( + [_FanConfig(_FanMode.SMART.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + self.set_fan_temp_config( + [_FanTempConfig(_FanMode.SMART.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + + def switch_to_default_mode(self): + self.set_fan_config( + [_FanConfig(_FanMode.DEFAULT.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + self.set_fan_temp_config( + [_FanTempConfig(_FanMode.DEFAULT.value, 0, 0, 0, 0, 0, 0, 0)] * self._fan_count + ) + + # def set_oled_cpu_message(self, message): + # return self._write([0x90] + + # list(message[:60].encode('ascii', 'ignore'))) + + # def set_oled_memory_message(self, message): + # return self._write([0x91] + + # list(message[:60].encode('ascii', 'ignore'))) + + # def set_oled_vga_message(self, message): + # return self._write([0x92] + + # list(message[:60].encode('ascii', 'ignore'))) + + # def set_oled_show_system_message(self): + # return self._write((0x72, )) + + # def set_oled_show_user_message(self): + # return self._write((0x74, )) + + # def set_oled_start_isp_process(self): + # return self._write((0xfa, )) + + # def set_oled_show_demo_mode(self): + # return self._write((0x77, 0xff)) + + # def set_reset_mcu(self): + # return self._write((0xd0, ), 0xcc, prefix=1) diff --git a/tests/test_msi.py b/tests/test_msi.py new file mode 100644 index 000000000..a39ce17f2 --- /dev/null +++ b/tests/test_msi.py @@ -0,0 +1,203 @@ +# uses the psf/black style + +from struct import pack +from datetime import datetime + + +import pytest +from _testutils import MockHidapiDevice, Report + +from liquidctl.driver.msi import MpgCooler, _REPORT_LENGTH, _DEFAULT_FEATURE_DATA, _LightingMode + + +@pytest.fixture +def mpgCoreLiquidK360Device(): + description = "Mock MPG CoreLiquid K360" + device = _MockCoreLiquid(vendor_id=0xFFFF, product_id=0xB130) + dev = MpgCooler(device, description) + + dev.connect() + return dev + + +@pytest.fixture +def mpgCoreLiquidK360DeviceInvalid(): + description = "Mock MPG CoreLiquid K360" + device = _MockCoreLiquidInvalid(vendor_id=0xFFFF, product_id=0xB130) + dev = MpgCooler(device, description) + + dev.connect() + return dev + + +class _MockCoreLiquid(MockHidapiDevice): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._fan_configs = (4, 20, 40, 50, 60, 70, 80, 90) + self._fan_temp_configs = (4, 30, 40, 50, 60, 70, 80, 90) + self._model_idx = 255 # TODO: check the correct model index from device + self._feature_data = Report(_DEFAULT_FEATURE_DATA[0], _DEFAULT_FEATURE_DATA[1:]) + + self.preload_read(self._feature_data) + + def write(self, data): + reply = bytearray(_REPORT_LENGTH) + reply[0:2] = data[0:2] + if list(data[:2]) == [0x01, 0xB1]: # get current model idx request + reply[2] = self._model_idx + elif list(data[:2]) == [0xD0, 0x31]: # get status request + reply[2:23] = ( + pack("