Add Linux support & circle preview

This commit is contained in:
P3n3tr4t0r
2025-06-19 21:54:54 +02:00
parent e54f8db157
commit 56119705e1
8 changed files with 162 additions and 189 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -1,27 +1,40 @@
# Important!
This is a fork of [corsair_lcd_tool](https://github.com/UDPSendToFailed/corsair_lcd_tool "corsair_lcd_tool") by [UDPSendToFailed](https://github.com/UDPSendToFailed "UDPSendToFailed") only tested on Linux. I deleted the openRGB stuff to just control the display.
# Features
- Display any image or GIF without size or length limits on the LCD screen of a compatible Corsair AIO.
- LED ring control with automatically calculated colors based on the currently loaded image.
- Saving and loading of the last image position, size, etc.
- Autostart which works fine on Windows, haven't tested it much on Linux.
- Low resources usage compared to iCUE.
# Planned Features
- .rpm installer
- Overlay text
- Implement temperatures
# Usage
- Install [Python](https://www.python.org/downloads/ "Python") 3.6 or newer.
- Install [OpenRGB](https://gitlab.com/CalcProgrammer1/OpenRGB "OpenRGB").
- Clone the repo or download as ZIP and extract it to a new folder.
- Install the required modules using `pip install -r requirements.txt`.
- If you are running on Linux run the install.sh to update your udev rules
- Run the script.
# Tested devices
- Corsair iCUE H170i ELITE LCD (non-XT), 0x1b1c / 0x0c39
- Corsair iCUE H170i ELITE LCD (non-XT), 0x1b1c / 0x0c39 (from Fork)
- Corsair iCUE ELITE CPU Cooler LCD Display Upgrade Kit, 0x1b1c / 0x0c39
# FAQ
- I don't want to control the LEDs with the script / use OpenRGB, how to disable the warning popup?
- Delete `led_controller_openrgb.py`, the rest of the script will work fine without it.
This was only tested on Linux.
It might work on Windows but to get sure use the original [corsair_lcd_tool by UDPSendToFailed](https://github.com/UDPSendToFailed/corsair_lcd_tool "corsair_lcd_tool by UDPSendToFailed")
# Why?
- Mostly because I was bored, and I don't want to keep iCUE installed just to be able to change the displayed image on the LCD. The script is 99% made by ChatGPT, so bugs / other issues can happen anytime as this is my first Python project which I started without any prior knowledge. Use at your own risk, I take no responsibility for any damage caused to your AIO, which is highly unlikely.
# WHY
Because Corsair decided to do nothing for Linux users.
If I have some time I might also integrate RGB Controllers in another repo!
# Thanks to
- [UDPSendToFailed](https://github.com/UDPSendToFailed "UDPSendToFailed") for the work. The main functionality is all in [corsair_lcd_tool](https://github.com/UDPSendToFailed/corsair_lcd_tool "corsair_lcd_tool")
- [browserdotsys](https://github.com/browserdotsys "browserdotsys") for [the gist](https://gist.github.com/browserdotsys/ef1b22c60c31d9c61e18cca30b3ce903 "the gist") that's used as a base of this script to communicate with the AIO.
# Any questions?
Feel free to contact me on Discord: @shutdown4life

View File

@@ -1,23 +1,10 @@
import logging
import os
import platform
import sys
import logging, os, platform, sys, cv2, numpy as np, yaml, usb.core, errno
from dataclasses import dataclass
import cv2
import hid
import numpy as np
import yaml
from PyQt6.QtCore import Qt, QTimer, QPointF, pyqtSignal, QThread, pyqtSlot, QRectF
from PyQt6.QtGui import QPixmap, QPainter, QMovie, QIcon, QTransform, QImage, QAction, QPalette, QColor
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QFileDialog, QSlider, QWidget, QGraphicsScene, \
QGraphicsView, QGraphicsPixmapItem, QSystemTrayIcon, QMenu, QStyleFactory, QGraphicsItem, QCheckBox
try:
from led_controller_openrgb import LEDController
led_controller_enabled = True
except ImportError:
led_controller_enabled = False
if platform.system() == 'Windows':
import winshell
elif platform.system() == 'Linux':
@@ -29,7 +16,6 @@ logging.basicConfig(level=logging.DEBUG)
VID = 0x1b1c # Corsair
PID = 0x0c39 # Corsair LCD Cap for Elite Capellix coolers
@dataclass
class CorsairCommand:
opcode: int # 0x02
@@ -65,7 +51,6 @@ class CorsairCommand:
def size(self):
return self.header_size + self.datalen
class UpdateDeviceThread(QThread):
captureSignal: pyqtSignal = pyqtSignal()
@@ -73,14 +58,51 @@ class UpdateDeviceThread(QThread):
super().__init__()
self.container = container
self.main = main_window
self.device = hid.device()
try:
self.device.open(VID, PID)
except Exception as e:
logging.error(f"Error opening device: {e}")
raise
self.device, self.interface, self.cfg = self.setup_usb_device(VID, PID)
self.interface_number = self.cfg[(0, 0)].bInterfaceNumber
QTimer.singleShot(0, self.start_timer)
def setup_usb_device(self, vid: int, pid: int):
# Find the USB device
device = usb.core.find(idVendor=vid, idProduct=pid)
if device is None:
raise RuntimeError(f"Corsair LCD device with VID {vid:#04x} and PID {pid:#04x} not found.")
# get config
cfg = device.get_active_configuration()
if cfg is None:
cfg = device[0] # first configuration if no active one
interface = cfg[(0, 0)] # Interface 0, Alt 0
# Only Linux: detach kernel driver if active
if platform.system() == "Linux":
if device.is_kernel_driver_active(interface.bInterfaceNumber):
try:
device.detach_kernel_driver(interface.bInterfaceNumber)
print(f"[INFO] Kernel driver for interface {interface.bInterfaceNumber} was successfully detached.")
except usb.core.USBError as e:
raise RuntimeError(f"[Error] Could not detach kernel driver: {e}")
# Now set configuration
try:
device.set_configuration()
except usb.core.USBError as e:
if e.errno == errno.EACCES:
raise PermissionError("[Access denied] Please set appropriate udev rules (Linux) or use administrator rights (Windows).")
elif e.errno == errno.EBUSY:
raise RuntimeError("[Device busy] Device is blocked by another process or the kernel.")
else:
raise
# claim interface
try:
usb.util.claim_interface(device, interface.bInterfaceNumber)
except usb.core.USBError as e:
raise RuntimeError(f"[Error] Could not claim interface: {e}")
return device, interface, cfg
def start_timer(self):
self.update_lcd_timer = QTimer()
self.update_lcd_timer.timeout.connect(self.update_lcd)
@@ -134,14 +156,19 @@ class UpdateDeviceThread(QThread):
part_num += 1
def write_command(self, data):
# Search for OUT endpoint
endpoint_out = usb.util.find_descriptor(
self.cfg[(0, 0)],
# match OUT endpoint
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
)
try:
commands = self.make_commands(data)
for command in commands:
self.device.write(command.to_bytes())
self.device.write(endpoint_out.bEndpointAddress, command.to_bytes())
except Exception as e:
logging.error(f"Error writing command to device: {e}")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
@@ -153,10 +180,6 @@ class MainWindow(QMainWindow):
self.tray_icon.setIcon(QIcon('icon.ico'))
self.window_state_handler = WindowStateHandler(self)
if led_controller_enabled:
self.led_controller = LEDController(self)
else:
logging.debug("LED controller is not enabled")
self.current_image_path = None
self.last_pixmap_pos = None
@@ -165,12 +188,12 @@ class MainWindow(QMainWindow):
self.last_scene_rect = None
self.last_scrollbar_pos = None
self.setWindowTitle('Corsair LCD Tool')
self.setWindowTitle('Corsair LCD Tool v2')
self.setFixedSize(600, 650)
self.container = QWidget(self)
self.container.setGeometry(60, 20, 480, 480)
self.container.setStyleSheet("background-color: #282c34; border: 0px")
self.container.setStyleSheet("border: 0px")
self.scene = QGraphicsScene(self.container)
self.view = NoScrollGraphicsView(self.scene, self.container)
@@ -286,27 +309,34 @@ class MainWindow(QMainWindow):
os.remove(self.shortcut_path)
logging.debug("Shortcut removed.")
elif platform.system() == "Linux":
service_content = f"""[Unit]
service_name = os.path.basename(self.script_path).replace(".py", ".service")
service_path = os.path.join(os.path.expanduser("~"), ".config/systemd/user", service_name)
service_content = f"""[Unit]
Description=Corsair LCD Tool
[Service]
ExecStart={self.python_path} {self.script_path}
Restart=on-failure
[Install]
WantedBy=default.target
"""
service_path = os.path.join(os.path.expanduser("~"), ".config/systemd/user", f"{os.path.basename(self.script_path)}.service")
os.makedirs(os.path.dirname(service_path), exist_ok=True)
if state:
with open(service_path, 'w') as f:
f.write(service_content)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
subprocess.run(["systemctl", "--user", "enable", os.path.basename(self.script_path)], check=True)
subprocess.run(["systemctl", "--user", "enable", service_name], check=True)
logging.debug("Systemd user service created and enabled.")
else:
if os.path.exists(service_path):
os.remove(service_path)
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
subprocess.run(["systemctl", "--user", "disable", service_name], check=True)
logging.debug("Systemd user service removed.")
def open_image(self):
@@ -319,46 +349,64 @@ class MainWindow(QMainWindow):
self.current_image_path = file_name
self.save_state_handler.restart_save_image_state_timer()
def create_circular_mask_overlay(self, size: int) -> QPixmap:
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QColor(0, 0, 0, 50))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRect(0, 0, size, size)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear)
painter.drawEllipse(0, 0, size, size)
painter.end()
return pixmap
def load_new_image(self, file_name):
logging.debug(f"Loading a new image: {file_name}")
self.scene.clear()
if file_name.lower().endswith('.gif'):
self.load_new_gif(file_name)
else:
if self.movie is not None:
self.movie.stop()
self.movie.deleteLater()
self.movie = None
pixmap = QPixmap(file_name)
self.pixmap_item = QGraphicsPixmapItem(pixmap)
self.pixmap_item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.scene.addItem(self.pixmap_item)
self.slider.setEnabled(True)
return
if self.movie is not None:
self.movie.stop()
self.movie.deleteLater()
self.movie = None
# load the image
pixmap = QPixmap(file_name)
self.pixmap_item = QGraphicsPixmapItem(pixmap)
self.pixmap_item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.pixmap_item.setZValue(1)
# create a circular mask overlay
mask_pixmap = self.create_circular_mask_overlay(480)
mask_item = QGraphicsPixmapItem(mask_pixmap)
mask_item.setOffset(0, 0)
# to ensure the circle mask is on top
mask_item.setZValue(10)
self.scene.addItem(self.pixmap_item)
self.scene.addItem(mask_item)
self.slider.setEnabled(True)
def load_new_gif(self, file_name):
if self.movie is not None:
self.movie.stop()
self.movie.deleteLater()
self.movie = QMovie(file_name)
self.pixmap_item = QGraphicsPixmapItem()
self.pixmap_item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.pixmap_item.setZValue(1)
self.scene.addItem(self.pixmap_item)
self.movie.frameChanged.connect(lambda: self.pixmap_item.setPixmap(QPixmap.fromImage(self.movie.currentImage())))
# create a circular mask overlay
mask_pixmap = self.create_circular_mask_overlay(480)
mask_item = QGraphicsPixmapItem(mask_pixmap)
mask_item.setOffset(0, 0)
mask_item.setZValue(10)
self.scene.addItem(mask_item)
# update the image on frame change
self.movie.frameChanged.connect(
lambda: self.pixmap_item.setPixmap(QPixmap.fromImage(self.movie.currentImage()))
)
self.movie.start()
self.slider.setEnabled(True)
def reset_image(self):
@@ -384,7 +432,6 @@ class MainWindow(QMainWindow):
logging.error(f"Error in capture_container: {e}")
return QImage()
class SaveStateHandler:
def __init__(self, main_window):
self.old_pos = None
@@ -502,7 +549,6 @@ class SaveStateHandler:
except Exception as e:
logging.error(f"Error in restart_save_image_state_timer: {e}")
class WindowStateHandler:
def __init__(self, main_window):
self.main = main_window
@@ -527,7 +573,6 @@ class WindowStateHandler:
except Exception as e:
logging.error(f"Error in minimize_window: {e}")
class NoScrollGraphicsView(QGraphicsView):
def __init__(self, scene, parent=None):
super().__init__(scene, parent)
@@ -539,12 +584,11 @@ class NoScrollGraphicsView(QGraphicsView):
def wheelEvent(self, event):
pass
if __name__ == "__main__":
try:
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
sys.exit(app.exec())
except Exception as e:
logging.error(f"Error in main: {e}")

13
install.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
echo "📦 Installing udev rule for Corsair device..."
# Copy udev rule
sudo cp udev/99-corsair.rules /etc/udev/rules.d/
# Reload udev rules
sudo udevadm control --reload-rules
sudo udevadm trigger
echo "✅ Done! Please replug the USB device now."

View File

@@ -1,116 +0,0 @@
import atexit
import math
import time
import logging
from PyQt6.QtCore import QThread, pyqtSignal, QTimer, QObject
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import QMessageBox
from openrgb import OpenRGBClient
from openrgb.utils import RGBColor
class LEDController(QThread):
captureSignal = pyqtSignal()
def __init__(self, main_window):
super().__init__()
self.main = main_window
self.enabled = True
self.client = self.connect_to_openrgb()
if not self.client:
QMessageBox.warning(None, "OpenRGB Connection Error",
"OpenRGB is required for LED control. "
"Please ensure OpenRGB is running and SDK Server is enabled.")
self.enabled = False
return
self.last_rgb_colors = [RGBColor(0, 0, 0)] * 24
self.worker = LEDWorker(self.client, self.last_rgb_colors, self)
self.started.connect(self.worker.set_colors)
QTimer.singleShot(0, self.start_timer)
atexit.register(self.stop_led)
def connect_to_openrgb(self):
for attempt in range(2):
try:
return OpenRGBClient()
except Exception as e:
if attempt == 0:
logging.warning("Retrying after 5 seconds...")
time.sleep(5)
return None
def start_timer(self):
self.update_led_timer = QTimer()
self.update_led_timer.timeout.connect(self.analyze_and_set_colors)
self.update_led_timer.start(100)
def analyze_and_set_colors(self, width=479, height=479, smoothing_factor=0.25, saturation_factor=1.25):
if not self.enabled:
return
try:
image = self.main.capture_container()
radius = min(width, height) // 2
for i in range(24):
angle = 2 * math.pi * (23 - i) / 24
x = width - int(width / 2 + radius * math.cos(angle))
y = int(height / 2 + radius * math.sin(angle))
color = QColor(image.pixel(x, y))
color = color.toHsv()
color.setHsv(color.hue(), min(255, int(color.saturation() * saturation_factor)), color.value())
rgb_color = RGBColor(color.red(), color.green(), color.blue())
self.last_rgb_colors[i] = RGBColor(
int(self.last_rgb_colors[i].red * (
1 - smoothing_factor) + rgb_color.red * smoothing_factor),
int(self.last_rgb_colors[i].green * (
1 - smoothing_factor) + rgb_color.green * smoothing_factor),
int(self.last_rgb_colors[i].blue * (
1 - smoothing_factor) + rgb_color.blue * smoothing_factor)
)
self.started.emit()
except Exception as e:
logging.error(f"Error updating LED: {e}")
def stop_led(self):
self.enabled = False
self.update_led_timer.stop()
self.worker.turn_off_leds()
class LEDWorker(QObject):
colorReady = pyqtSignal(object)
def __init__(self, client, last_rgb_colors, controller):
super().__init__()
self.client = client
self.last_rgb_colors = last_rgb_colors
self.controller = controller
def set_colors(self):
if not self.controller.enabled:
return
try:
for device in self.client.ee_devices:
if device.name == "Corsair Commander Core":
pump_zone = next(zone for zone in device.zones if zone.name == "Pump")
for i, rgb_color in enumerate(self.last_rgb_colors):
pump_zone.leds[i].set_color(rgb_color, fast=True)
except Exception as e:
logging.error(f"Error setting colors: {e}")
def turn_off_leds(self):
time.sleep(0.1)
for device in self.client.ee_devices:
if device.name == "Corsair Commander Core":
device.clear()

View File

@@ -4,5 +4,5 @@ opencv-python
openrgb-python
PyQt6
pyyaml
pywin32
winshell
winshell
pyusb

17
state.yaml Normal file
View File

@@ -0,0 +1,17 @@
lastImagePath: /home/nick/Downloads/cs2logo.gif
last_pixmap_pos:
- -64.00000000000003
- -82.00000000000011
last_scene_rect:
- -137.0
- -95.0
- 890.0
- 815.0
last_scrollbar_pos:
- 0
- 0
last_slider_value: 74
last_transform:
- 1.0
- 1.0
startup_checkbox_state: false

1
udev/99-corsair.rules Normal file
View File

@@ -0,0 +1 @@
SUBSYSTEM=="usb", ATTR{idVendor}=="1b1c", ATTR{idProduct}=="0c39", MODE="0666", GROUP="plugdev"