diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md index 75fc03c..5bbd6b0 100644 --- a/README.md +++ b/README.md @@ -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 + diff --git a/corsair_lcd_tool.py b/corsair_lcd_tool.py index fd8cec6..53779be 100644 --- a/corsair_lcd_tool.py +++ b/corsair_lcd_tool.py @@ -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}") diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..80b2965 --- /dev/null +++ b/install.sh @@ -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." \ No newline at end of file diff --git a/led_controller_openrgb.py b/led_controller_openrgb.py deleted file mode 100644 index 34f3e84..0000000 --- a/led_controller_openrgb.py +++ /dev/null @@ -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() diff --git a/requirements.txt b/requirements.txt index a0293d3..967c387 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,5 @@ opencv-python openrgb-python PyQt6 pyyaml -pywin32 -winshell \ No newline at end of file +winshell +pyusb \ No newline at end of file diff --git a/state.yaml b/state.yaml new file mode 100644 index 0000000..aaa8779 --- /dev/null +++ b/state.yaml @@ -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 diff --git a/udev/99-corsair.rules b/udev/99-corsair.rules new file mode 100644 index 0000000..19a01c1 --- /dev/null +++ b/udev/99-corsair.rules @@ -0,0 +1 @@ +SUBSYSTEM=="usb", ATTR{idVendor}=="1b1c", ATTR{idProduct}=="0c39", MODE="0666", GROUP="plugdev" \ No newline at end of file