msi: support Coreliquid K360 and two similar variants (#564)

This commit is contained in:
Aapo Kössi 2024-01-28 09:09:48 +02:00 committed by GitHub
parent c1cb21e2e2
commit 912dd0ad61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2581 additions and 18 deletions

4
.gitignore vendored
View File

@ -5,4 +5,6 @@
/dist/
__pycache__/
liquidctl/_version.py
.DS_Store
.DS_Store
.coverage
.vscode/

View File

@ -128,6 +128,7 @@ Device notes are sorted alphabetically, major (upper case) notes before minor
| AIO liquid cooler | ~~[Corsair iCUE Elite Capellix H100i, H115i, H150i](docs/corsair-commander-core-guide.md)~~ | <sup>_Bp_</sup> | 1.11.1 |
| AIO liquid cooler | [Corsair iCUE Elite RGB H100i, H150i](docs/corsair-platinum-pro-xt-guide.md) | | 1.13.0 |
| AIO liquid cooler | [EVGA CLC 120 (CL12), 240, 280, 360](docs/asetek-690lc-guide.md) | <sup>_Z_</sup> | 1.9.1 |
| AIO liquid cooler | [MSI MPG Coreliquid K360](docs/msi-mpg-coreliquid-guide.md) | <sup>_penx_</sup> | 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) | <sup>_LZ_</sup> | 1.9.1 |
| AIO liquid cooler | [NZXT Kraken X31, X41, X61](docs/asetek-690lc-guide.md) | <sup>_LZ_</sup> | 1.9.1 |

View File

@ -0,0 +1,312 @@
# 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.
### 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 (2 seconds has proven safe) 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 |

View File

@ -0,0 +1,206 @@
# 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 lighting 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.
```
# liquidctl initialize --pump-mode smart
MSI MPG Coreliquid K360
├── Display firmware version 2
├── APROM firmware version 0
├── LDROM firmware version 256
├── Serial number A02020123456
└── Pump mode smart
```
## 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 fans (`fans`, `fan1`, `fan2`, `fan3`) | 20% | 100% | |
| `waterblock-fan` | 0% | 100% | |
For profiles, one or more temperatureduty 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 only supported channel argument is "sync".
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.
Lighting effect speed can be controlled with the `--speed` parameter, which can vary between 0 and 3.
```
# liquidctl set sync color clock aa00aa 00aaaa --speed 0
# liquidctl set sync color steady ffb6c1
```
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 is not yet exposed by the liquidctl cli, but it is included in the driver as the color_selection flag.
```
>>> from liquidctl.driver import find_liquidctl_devices
>>> coreliquid_device = next(find_liquidctl_devices())
>>> coreliquid_device.connect()
>>> coreliquid_device.set_colors("sync", "clock", [], color_selection=0)
```
## 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-100) can also be controlled. The only channel available for the K360 model is "lcd".
Uploading and choosing which images or banner backgrounds to display is experimentally supported, sometimes an uploaded file may be viewable from a different index on the device than the one it was originally uploaded to.
```
# liquidctl set lcd screen settings '80;0'
# liquidctl set lcd screen hardware 'cpu_temp;cpu_freq;fan_radiator'
```
```
# liquidctl set lcd screen banner '1;4;A cool message in 62 characters!;/home/username/Pictures/pic.jpg'
# liquidctl set lcd screen banner '0;1;Hello, MSI?'
# liquidctl set lcd screen banner '1;4;I can also show a previously uploaded background!'
```
```
# liquidctl set lcd screen disable
```
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 is 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 | \<type (0=preset,1=custom)\>;\<index (0-5)\>[;\<filename\>] |
| banner | set the screen to display a message with custom or preset image as background | \<type (0=preset,1=custom)\>;\<index (0-3)\>;\<message\>[;\<filename\>]
| 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 | \<brightness (0-100)\>;\<direction (0-3)\> |
| disable | disables the lcd screen | |
The index field of image and banner modes denotes which of the device-stored images to show on the display, or to which save slot to store an uploaded image in. Use type=0 to display one of the preset animations, and type=1 to upload or display a custom image.
| 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 |

View File

@ -464,6 +464,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"

View File

@ -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 temperatureduty 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,92 @@ 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 == '<number>' 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 == '<number>' 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):
"""Communicate sensor data directly with the device.
Implemented for use with the MSI coreliquid AIO.
Allows compatible devices to utilize their internal control loop
to determine appropriate fan speeds for the CPU temperature.
"""
assert getattr(
device, 'HAS_AUTOCONTROL', False
), f'No registered control loop capability for device {device}!'
from datetime import datetime
device.set_profiles(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 +278,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 +310,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 +320,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 +331,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 +345,22 @@ if __name__ == '__main__':
if args['show-sensors']:
show_sensors(device)
elif args['control']:
control(device, args['<channel>'], list(map(parse_profile, args['<profile>'])),
args['<sensor>'], update_interval=int(args['--interval']))
if args['--use-device-controller']:
auto_control(
device,
args['<channel>'],
list(map(lambda p: parse_profile(p, str_allowed=True), args['<profile>'])),
args['<sensor>'],
update_interval=int(args['--interval']),
)
else:
control(
device,
args['<channel>'],
list(map(parse_profile, args['<profile>'])),
args['<sensor>'],
update_interval=int(args['--interval']),
)
else:
raise Exception('nothing to do')
except KeyboardInterrupt:

View File

@ -376,6 +376,68 @@ Channel Mode #colors (Platinum) #colors (Pro XT/Elite RGB) #colors (Platinum SE)
\fIled\fR \fIsuper\-fixed\fR 24 16 48
.TE
.
.SS MSI MPG Coreliquid k360
Cooling channels: \fIfan1\fR, \fIfan2\fR, \fIfan3\fR, \fIfans\fR, \fIwaterblock-fan\fR, \fIpump\fR.
.PP
Lighting channels: \fIsync\fR.
.PP
LCD screens: \fIlcd\fR.
.TS
l c
----
l c
.
Mode #colors
\fIdisable\fR 0
\fIsteady\fR 1
\fIblink\fR 0
\fIbreathing\fR 1
\fIclock\fR 2
\fIcolor pulse\fR 0
\fIcolor ring\fR 0
\fIcolor ring double flashing\fR 0
\fIcolor ring flashing\fR 0
\fIcolor shift\fR 0
\fIcolor wave\fR 2
\fIcorsair ique\fR 0
\fIdisable2\fR 0
\fIdouble flashing\fR 1
\fIdouble meteor\fR 0
\fIenergy\fR 0
\fIfan control\fR 0
\fIfire\fR 2
\fIflashing\fR 1
\fIjazz\fR 0
\fIjrainbow\fR 0
\fIlava\fR 0
\fIlightning\fR 1
\fImarquee\fR 1
\fImeteor\fR 1\(en2
\fImovie\fR 0
\fImsi marquee\fR 1
\fImsi rainbow\fR 0
\fIplanetary\fR 0
\fIplay\fR 0
\fIpop\fR 0
\fIrainbow\fR 0
\fIrainbow double flashing\fR 0
\fIrainbow flashing\fR 0
\fIrainbow wave\fR 0
\fIrandom\fR 0
\fIrap\fR 0
\fIstack\fR 1
\fIvisor\fR 2
\fIwater drop\fR 1
.TE
.PP
When applicable the animation speed can be set with
.BI \-\-speed= value ,
where the allowed values are: \fI0\fR, \fI1\fR and \fI2\fR.
The LCD screen settings can be controlled with the
\fBsettings\fR flag, that requires the arguments "\fIbrightness\fR;\fIdirection\fR"
where an allowed brightness is an integer in the range \fI0-100\fR,
and orientations \fI0-3\fR correspond to the orientations up, right, down and left.
.
.SS NZXT Kraken X40, X60
.SS NZXT Kraken X31, X41, X61
Supports the same modes and options as a Corsair Hydro H80i GT (or similar), but

View File

@ -34,6 +34,7 @@ from liquidctl.driver import corsair_hid_psu
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

1538
liquidctl/driver/msi.py Normal file

File diff suppressed because it is too large Load Diff

332
tests/test_msi.py Normal file
View File

@ -0,0 +1,332 @@
# 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
from liquidctl.error import UnsafeFeaturesNotEnabled
@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 mpgCoreLiquidDeviceExperimental():
_, pid, desc, kwargs = MpgCooler._MATCHES[-1]
description = "Mock " + desc
unsafe = kwargs["unsafe"]
device = _MockCoreLiquid(vendor_id=0xFFFF, product_id=pid)
dev = MpgCooler(device, description, unsafe=unsafe)
dev.connect(unsafe=unsafe)
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("<h", 496)
+ pack("<h", 517)
+ pack("<h", 509)
+ pack("<h", 1045)
+ pack("<h", 1754)
+ bytearray([0, 0, 0, 0, 0x7D, 0, 0x7D, 0, 0, 0])
+ pack("<h", 20)
+ pack("<h", 20)
+ pack("<h", 20)
+ pack("<h", 20)
+ pack("<h", 50)
)
self.preload_read(Report(0, reply))
elif list(data[:2]) == [0xD0, 0x32]: # get fan config request
for i in (2, 10, 18, 26, 34):
reply[i : i + 10] = self._fan_configs
self.preload_read(Report(0, reply))
elif list(data[:2]) == [0xD0, 0x33]: # get fan T config request
reply[1] = 0x32
for i in (2, 10, 18, 26, 34):
reply[i : i + 10] = self._fan_temp_configs
self.preload_read(Report(0, reply))
elif list(data[1:3]) == [0xB0, 0xCC]: # get ldrom fw
self.preload_read(Report(0, reply))
elif list(data[1:3]) == [0xB6, 0xCC]: # get aprom fw
self.preload_read(Report(0, reply))
elif data[1] == 0xF1: # get screen fw
self.preload_read(Report(0, reply))
return super().write(data)
class _MockCoreLiquidInvalid(MockHidapiDevice):
def read(self, length):
buf = bytearray([0xD0, 0x32] + (62 * [0]))
return buf[:length]
def test_mpg_core_liquid_k360_initializes(mpgCoreLiquidK360Device):
mpgCoreLiquidK360Device.initialize()
writes = len(mpgCoreLiquidK360Device.device.sent)
assert (
writes
== 9
# 1 get fan config,
# 2 get fan T config,
# 3 get aprom fw,
# 4 get ldrom fw,
# 5 get display fw,
# 6 set screen settings,
# 7 set fan config,
# 8 set fan T config,
# 9 set safe control temperature
)
def test_mpg_core_liquid_k360_get_status(mpgCoreLiquidK360Device):
dev = mpgCoreLiquidK360Device
(
fan1,
fan1d,
fan2,
fan2d,
fan3,
fan3d,
wbfan,
wbfand,
pump,
pumpd,
) = dev.get_status()
assert fan1[1] == 496
assert fan1d[1] == 20
assert fan2[1] == 517
assert fan2d[1] == 20
assert fan3[1] == 509
assert fan3d[1] == 20
assert wbfan[1] == 1045
assert wbfand[1] == 20
assert pump[1] == 1754
assert pumpd[1] == 50
assert dev.device.sent[-1].number == 0xD0
assert dev.device.sent[-1].data[0] == 0x31
def test_mpg_core_liquid_k360_get_status_invalid_read(mpgCoreLiquidK360DeviceInvalid):
dev = mpgCoreLiquidK360DeviceInvalid
with pytest.raises(AssertionError):
dev.get_status()
def test_mpg_core_liquid_k360_set_fixed_speeds(mpgCoreLiquidK360Device):
mpgCoreLiquidK360Device.set_fixed_speed("pump", 65)
fan_report, T_report = mpgCoreLiquidK360Device.device.sent[-2:]
assert fan_report.data[33:41] == [3] + [65] * 7
def test_mpg_core_liquid_k360_set_speed_profile(mpgCoreLiquidK360Device):
duties = [20, 30, 34, 40, 50]
temps = [30, 50, 80, 90, 100]
curve_profile = zip(duties, temps)
mpgCoreLiquidK360Device.set_speed_profile("fans", curve_profile)
fan_report, T_report = mpgCoreLiquidK360Device.device.sent[-3:-1]
# fan 1
assert fan_report.data[1:9] == [3] + duties + [0] * 2
assert T_report.data[1:9] == [3] + temps + [0] * 2
# fan 2
assert fan_report.data[9:17] == [3] + duties + [0] * 2
assert T_report.data[9:17] == [3] + temps + [0] * 2
# fan 3
assert fan_report.data[17:25] == [3] + duties + [0] * 2
assert T_report.data[17:25] == [3] + temps + [0] * 2
def test_mpg_core_liquid_k360_set_color(mpgCoreLiquidK360Device):
colors = [[255, 255, 0], [0, 255, 255]]
mode = "clock"
speed = 2
brightness = 5
use_color = 1
mpgCoreLiquidK360Device.set_color(
"sync", mode, colors, speed=speed, brightness=brightness, color_selection=use_color
)
report = mpgCoreLiquidK360Device.device.sent[-1]
assert report.data[30] == 20 # mode
assert report.data[34] == (brightness << 2) | speed # brightness, speed
assert report.data[31:34] == colors[0]
assert report.data[35:38] == colors[1]
def test_mpg_core_liquid_k360_not_totally_broken(mpgCoreLiquidK360Device):
"""Reasonable calls to untested APIs do not raise exceptions"""
dev = mpgCoreLiquidK360Device
dev.initialize()
_ = dev.get_status()
profile = ((0, 30), (25, 40), (60, 60), (100, 75))
dev.set_speed_profile("pump", profile)
dev.set_fixed_speed("waterblock-fan", 42)
dev.set_screen("lcd", "image", "0;4")
dev.set_screen("lcd", "banner", "1;0;Hello, world")
dev.set_screen("lcd", "disable", "")
dev.set_screen("lcd", "clock", "0")
dev.set_screen("lcd", "hardware", "cpu_temp;cpu_freq")
def test_mpg_core_liquid_k360_set_clock(mpgCoreLiquidK360Device):
time = datetime(2012, 12, 21, 9, 54, 20)
mpgCoreLiquidK360Device.set_time(time)
report = mpgCoreLiquidK360Device.device.sent[-1]
assert report.data[:8] == [131, 12, 12, 21, 4, 9, 54, 20]
def test_mpg_core_liquid_k360_set_hw_status(mpgCoreLiquidK360Device):
cpu_freq = 3500.0
cpu_T = 54.0
gpu_f = 7000
mpgCoreLiquidK360Device.set_hardware_status(cpu_T, cpu_f=cpu_freq, gpu_f=gpu_f)
cpu_report = mpgCoreLiquidK360Device.device.sent[-2]
assert cpu_report.data[:5] == [133, 172, 13, 54, 0]
gpu_report = mpgCoreLiquidK360Device.device.sent[-1]
assert gpu_report.data[:5] == [134, 88, 27, 0, 0]
def test_unsafe_core_liquid_get_status(mpgCoreLiquidDeviceExperimental):
status = mpgCoreLiquidDeviceExperimental.get_status()
assert status == []
status = mpgCoreLiquidDeviceExperimental.get_status(unsafe=["other"])
assert status == []
status = mpgCoreLiquidDeviceExperimental.get_status(unsafe=["experimental_coreliquid_cooler"])
assert mpgCoreLiquidDeviceExperimental.device.sent[-1].number == 0xD0
def test_unsafe_core_liquid_set_fixed_speed(mpgCoreLiquidDeviceExperimental):
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_fixed_speed("pump", 65)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_fixed_speed("pump", 65, unsafe=["other"])
mpgCoreLiquidDeviceExperimental.set_fixed_speed(
"pump", 65, unsafe=["experimental_coreliquid_cooler"]
)
def test_unsafe_core_liquid_set_speed_profile(mpgCoreLiquidDeviceExperimental):
duties = [20, 30, 34, 40, 50]
temps = [30, 50, 80, 90, 100]
curve_profile = zip(duties, temps)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_speed_profile("fans", curve_profile)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_speed_profile("fans", curve_profile, unsafe=["other"])
mpgCoreLiquidDeviceExperimental.set_speed_profile(
"fans", curve_profile, unsafe=["experimental_coreliquid_cooler"]
)
def test_unsafe_core_liquid_set_color(mpgCoreLiquidDeviceExperimental):
colors = [[255, 255, 0], [0, 255, 255]]
mode = "clock"
speed = 2
brightness = 5
use_color = 1
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_color(
"sync", mode, colors, speed=speed, brightness=brightness, color_selection=use_color
)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_color(
"sync",
mode,
colors,
speed=speed,
brightness=brightness,
color_selection=use_color,
unsafe=["other"],
)
mpgCoreLiquidDeviceExperimental.set_color(
"sync",
mode,
colors,
speed=speed,
brightness=brightness,
color_selection=use_color,
unsafe=["experimental_coreliquid_cooler"],
)
def test_unsafe_core_liquid_set_clock(mpgCoreLiquidDeviceExperimental):
time = datetime(2012, 12, 21, 9, 54, 20)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_time(time)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_time(time, unsafe=["other"])
mpgCoreLiquidDeviceExperimental.set_time(time, unsafe=["experimental_coreliquid_cooler"])
def test_unsafe_core_liquid_set_hw_status(mpgCoreLiquidDeviceExperimental):
cpu_freq = 3500.0
cpu_T = 54.0
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_hardware_status(cpu_T, cpu_f=cpu_freq)
with pytest.raises(UnsafeFeaturesNotEnabled):
mpgCoreLiquidDeviceExperimental.set_hardware_status(cpu_T, cpu_f=cpu_freq, unsafe=["other"])
mpgCoreLiquidDeviceExperimental.set_hardware_status(
cpu_T, cpu_f=cpu_freq, unsafe=["experimental_coreliquid_cooler"]
)