Add Linux support & circle preview
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
29
README.md
29
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
|
||||
|
||||
|
||||
@@ -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
13
install.sh
Normal 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."
|
||||
@@ -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()
|
||||
@@ -4,5 +4,5 @@ opencv-python
|
||||
openrgb-python
|
||||
PyQt6
|
||||
pyyaml
|
||||
pywin32
|
||||
winshell
|
||||
winshell
|
||||
pyusb
|
||||
17
state.yaml
Normal file
17
state.yaml
Normal 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
1
udev/99-corsair.rules
Normal file
@@ -0,0 +1 @@
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="1b1c", ATTR{idProduct}=="0c39", MODE="0666", GROUP="plugdev"
|
||||
Reference in New Issue
Block a user