Last active
June 22, 2023 23:12
-
-
Save Jacajack/dc40a4a7883149937a1da51c07a9c6c4 to your computer and use it in GitHub Desktop.
A simple script for Razer BlackWidow V4 command dial support with OpenRazer and evdev
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import asyncio | |
import evdev | |
import evdev.ecodes as ecodes | |
import openrazer.client | |
import colorsys | |
import time | |
import math | |
import random | |
# Distributes dial events to sub-functions. When pressed, the next function is activated | |
class CommandDialFunctionMux: | |
def __init__(self, on_change = None): | |
self.mode_id = 0 | |
self.modes = [] | |
self.last_change_time = time.time() | |
self.on_change = on_change | |
def add_mode(self, name, color, function): | |
self.modes.append({ | |
'name': name, | |
'color': color, | |
'function': function, | |
'counter': 0 | |
}) | |
def current_mode(self): | |
return self.modes[self.mode_id] | |
def on_turn(self, delta): | |
mode = self.current_mode() | |
mode['counter'] += delta | |
mode['function'](self, mode['counter'], delta, False) | |
self.last_change_time = time.time() | |
if self.on_change: | |
self.on_change(self) | |
def on_press(self): | |
self.mode_id = (self.mode_id + 1) % len(self.modes) | |
self.last_change_time = time.time() | |
print(f"Command dial function {self.mode_id}") | |
if self.on_change: | |
self.on_change(self) | |
def get_color(self): | |
return self.current_mode()['color'] | |
def time_since_change(self): | |
return time.time() - self.last_change_time | |
# A nice glimmering effect, very suboptimal, but | |
# works well as an example | |
class GlimmerEffect: | |
def __init__(self, kbd, hue = 1): | |
self.kbd = kbd | |
self.hue = 1 | |
self.intensity = 0.85 | |
self.speed = 1.5 | |
self.phases = {} | |
def on_dial(self, mux, counter, delta, press): | |
self.hue = counter % 128 / 128 | |
def update(self): | |
t = time.time() | |
for y in range(kbd.fx.advanced.rows): | |
for x in range(kbd.fx.advanced.cols): | |
phase = self.phases.get((x, y)) | |
if phase is None: | |
self.phases[(x, y)] = random.random() | |
phase = self.phases[(x, y)] | |
s = 1 | |
v_offset = math.sin(t * self.speed + phase * 2 * math.pi) ** 8 | |
v = (1 - self.intensity) + self.intensity * v_offset | |
h = (self.hue + v_offset * self.intensity * 0.01) % 1 | |
kbd.fx.advanced.matrix[y, x] = tuple(x * 255 for x in colorsys.hsv_to_rgb(h, s, v)) | |
# Emits scroll events to the provided mouse device | |
class Scroller: | |
def __init__(self, dev, code): | |
self.dev = dev | |
self.code = code | |
def on_dial(self, mux, counter, delta, press): | |
if delta: | |
self.dev.write(ecodes.EV_REL, self.code, delta) | |
self.dev.syn() | |
# Handles events coming from the original mouse device | |
async def handle_mouse(dial_handler): | |
async for event in razer_mouse.async_read_loop(): | |
# Handle and filter out any horizontal scroll events | |
# We grabbed the mouse device, so we re-emit other events like volume control | |
if event.type == ecodes.EV_REL and event.code == ecodes.REL_HWHEEL: | |
dial_handler.on_turn(event.value) | |
elif event.type == ecodes.EV_REL and event.code == ecodes.REL_HWHEEL_HI_RES: | |
pass | |
else: | |
virtual_mouse.write_event(event) | |
# Handles events coming from the original kbd device | |
async def handle_keyboard(dial_handler): | |
async for event in razer_kbd.async_read_loop(): | |
# We're only interested in F24 (command dial press) | |
# No need to re-emit, we don't own the kbd here | |
if event.type == ecodes.EV_KEY and event.code == ecodes.KEY_F24 and event.value == 1: | |
dial_handler.on_press() | |
# Returns numbers in specified intervals | |
async def effect_updater(dt): | |
i = 0 | |
while True: | |
yield i | |
await asyncio.sleep(dt) | |
i += 1 | |
# Performs a single effect update | |
# Called periodically and whenever the dial is turned | |
def update_effect(dial_mux, effect): | |
effect.update() | |
if dial_mux.time_since_change() < 3: | |
effect.kbd.fx.advanced.matrix[0, 1] = dial_mux.get_color() | |
effect.kbd.fx.advanced.draw() | |
# Responsible for updating the keyboard effect in specified intervals | |
async def effect_loop(dial_mux, effect): | |
async for i in effect_updater(0.05): | |
update_effect(dial_mux, effect) | |
if __name__ == '__main__': | |
devman = openrazer.client.DeviceManager() | |
kbd = devman.devices[0] # TODO pick BlackWidow V4 Pro | |
# FIXME hardcoded paths | |
razer_mouse = evdev.InputDevice("/dev/input/by-id/usb-Razer_Razer_BlackWidow_V4_Pro-if02-event-mouse") | |
razer_kbd = evdev.InputDevice("/dev/input/by-id/usb-Razer_Razer_BlackWidow_V4_Pro-if01-event-kbd") | |
# Extend capabilities defined by the original mouse device | |
razer_mouse_cap = razer_mouse.capabilities(absinfo = False, verbose = False) | |
mouse_cap = { | |
ecodes.EV_REL: razer_mouse_cap[ecodes.EV_REL] + [ecodes.REL_WHEEL, ecodes.REL_HWHEEL], | |
ecodes.EV_KEY: razer_mouse_cap[ecodes.EV_KEY] + [ecodes.KEY_VOLUMEUP, ecodes.KEY_VOLUMEDOWN], | |
} | |
# Virtual devices where we send our events | |
virtual_mouse = evdev.UInput(mouse_cap, name = "command_dial_mouse", version = 0x3) | |
virtual_kbd = evdev.UInput.from_device(razer_kbd, name = "command_dial_kbd") | |
# Keyboard matrix effect | |
glimmer_effect = GlimmerEffect(kbd) | |
effect = glimmer_effect | |
# These guys are used for scrolling with the command dial | |
scroller_v = Scroller(dev = virtual_mouse, code = ecodes.REL_WHEEL) | |
scroller_h = Scroller(dev = virtual_mouse, code = ecodes.REL_HWHEEL) | |
dial_mux = CommandDialFunctionMux(on_change = lambda mux: update_effect(mux, effect)) | |
dial_mux.add_mode( | |
name = "Vertical scroll", | |
color = (255, 255, 0), | |
function = scroller_v.on_dial | |
) | |
dial_mux.add_mode( | |
name = "Horizontal scroll", | |
color = (255, 0, 255), | |
function = scroller_h.on_dial | |
) | |
dial_mux.add_mode( | |
name = "Glimmer color", | |
color = (255, 255, 255), | |
function = glimmer_effect.on_dial | |
) | |
# Grab mouse and start our main loop | |
with razer_mouse.grab_context(): | |
asyncio.ensure_future(handle_mouse(dial_mux)) | |
asyncio.ensure_future(handle_keyboard(dial_mux)) | |
asyncio.ensure_future(effect_loop(dial_mux, effect)) | |
event_loop = asyncio.get_event_loop() | |
event_loop.run_forever() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment