diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5de493e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] + +members = [ + "lib", "cli", "gui" +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..126c31d --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# deathadderv2-rgb + +Set (a constant) color on the Razer DeathAdder v2. (while practising in rust) + +A little utility for those of us that don't want to run 2-3 apps and 6 services (!!) just for keeping this mouse from changing colors like a christmas tree. Personally, I don't care about the auto-switching of profiles that Synapse provides and all the other functionality I can have without running Razer's apps in the background. + +Unfortunately, the device does not remember the color and it comes back with the rainbow on power on (either after sleep/hibernation or on boot). Not going to bother making it into a service as the task scheduler suits me just fine (read below if you're interested in maintaining the setting). + +## Requirements + +Only requirement is to be using the libusb0 driver. + +One way to install it is using [Zadig](https://zadig.akeo.ie/). You only need to change the entry "Razer DeathAdder V2 (Interface 3)". Use the spinner to select "libusb-win32 (vX.Y.Z)" and hit "Replace driver". In my case (Win11) it timed out while creating a restore point but it actually installed it. + +## Usage + +The tool comes in two forms, a console executable that you can use like so: + +``` +> deathadder-rgb-cli aabbcc +``` + +and a GUI app that will just pop up a color picker prompt (check the mouse while selecting). + +You can use the GUI version with command line arguments too (same usage as above), except a console window will not be allocated (this is intentional). + +I have not found a way to retrieve the current color from the device so both apps will save the last sent color to a file under %APPDATA%/deathadder/config/default-config.toml. + +### Bonus + +It is actually possible to set a different color on the scroll wheel (Synapse doesn't support this at the time of this writing). But there's a catch: most combinations don't work and I don't understand why. For sure it accepts combinations when the RGB components in both colors are the same even if in different order. For instance, the following will work: + +``` +> deathadder-rgb-cli 1bc c1b +> deathadder-rgb-cli 1155AA AA5511 +> deathadder-rgb-cli 10f243 f24310 +``` + +### Task Scheduler: re-applying the setting + +The GUI version also supports `--last` as the first argument in which case it sets the last applied color (either from cli or gui). This is useful if you want to schedule a task that does not pop up any windows. + +A tested setup is to set a trigger at log on, and for waking up from sleep, a custom trigger on Power-Troubleshooter with event ID 1 and delay 5 seconds. In Action tab use the absolute path to `deathadder-rgb-gui` and in the arguments put `--last`. + +## Technical + +I captured the USB using UsbPcap while Synapse was sending the color-setting commands (it was a single control transfer-write, multiple times to provide that fade effect) and replaced the RGB values in it. The rest of the packet is identical. Haven't tested in any mouse other than mine; not sure if there's anything device-specific in there that would prevent others from using it. + +The USB message header was: + +``` +Setup Data + bmRequestType: 0x21 + bRequest: SET_REPORT (0x09) + wValue: 0x0300 + wIndex: 0 + wLength: 90 + Data Fragment: 001f[...] +``` + +And this is would be the payload for setting the color to bright white: + +``` +File: lib/src/lib.rs + +[...] +// the start (no idea what they are) +0x00, 0x1f, 0x00, 0x00, 0x00, 0x0b, 0x0f, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01, + +// wheel RGB (3B) | body RGB (3B) +0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + +// the trailer (no idea what they are either) +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00 + +[...] +``` + diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..fa635f7 --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "deathadder-rgb-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "deathadder-rgb-cli" +path = "src/cli.rs" + +[dependencies] +libdeathadder = { path = "../lib" } +rgb = "0.8.36" \ No newline at end of file diff --git a/cli/src/cli.rs b/cli/src/cli.rs new file mode 100644 index 0000000..ca4e349 --- /dev/null +++ b/cli/src/cli.rs @@ -0,0 +1,34 @@ +use rgb::RGB8; +use libdeathadder::core::{rgb_from_hex, Config}; +use libdeathadder::v2::set_color; + +fn main() { + let args: Vec = std::env::args().collect(); + + let parse_arg = |input: &str| -> RGB8 { + match rgb_from_hex(input) { + Ok(rgb) => rgb, + Err(e) => panic!("argument '{}' should be in the \ + form [0x/#]RGB[h] or [0x/#]RRGGBB[h] where R, G, and B are hex \ + digits: {}", input, e) + } + }; + + let (color, wheel_color) = match args.len() { + ..=1 => { + match Config::load() { + Some(cfg) => (cfg.color, cfg.wheel_color), + None => panic!("failed to load configuration; please specify \ + arguments manually") + } + }, + 2 => (parse_arg(args[1].as_ref()), None), + 3 => (parse_arg(args[1].as_ref()), Some(parse_arg(args[2].as_ref()))), + _ => panic!("usage: {} [(body) color] [wheel color]", args[0]) + }; + + match set_color(color, wheel_color) { + Ok(msg) => println!("{}", msg), + Err(e) => panic!("Failed to set color(s): {}", e) + } +} diff --git a/gui/Cargo.toml b/gui/Cargo.toml new file mode 100644 index 0000000..e9116ee --- /dev/null +++ b/gui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "deathadder-rgb-gui" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "deathadder-rgb-gui" +path = "src/gui.rs" + +[dependencies] +libdeathadder = { path = "../lib" } +winapi = "0.3.9" +rgb = "0.8.36" + +[dependencies.windows] +version = "0.44.0" +features = [ + "Win32_Foundation", + "Win32_UI_Controls_Dialogs", + "Win32_UI_WindowsAndMessaging", + "Win32_System_Diagnostics_Debug" +] \ No newline at end of file diff --git a/gui/src/gui.rs b/gui/src/gui.rs new file mode 100644 index 0000000..1d29faf --- /dev/null +++ b/gui/src/gui.rs @@ -0,0 +1,268 @@ +#![windows_subsystem = "windows"] + +use std::{mem::size_of, ffi::CStr, thread, sync::{Arc, Mutex}}; +use core::time::Duration; +use windows::{ + core::PCSTR, + Win32::{ + Foundation::{HWND, WPARAM, LPARAM, RECT, COLORREF}, + UI::{ + WindowsAndMessaging::{ + WM_INITDIALOG, WM_COMMAND, WM_PAINT, EN_UPDATE, GetWindowTextA, + SWP_NOSIZE, SWP_NOZORDER, GetWindowRect, GetDesktopWindow, + GetClientRect, SetWindowPos, + }, + Controls::Dialogs::* + }, + System::Diagnostics::Debug::OutputDebugStringA + }, +}; +use rgb::RGB8; +use libdeathadder::core::{rgb_from_hex, Config}; +use libdeathadder::v2::{preview_color, set_color}; + + +/* + * This utility operates in either command line mode or UI mode. + * + * In command line mode the colors are specified as cmd line arguments + * and no UI is shown. The reason for this mode is to be able to automate or + * schedule this tool using the task scheduler or smth. If this were a true + * console application (without the #![windows_subsystem = "windows"] directive + * above), in such scenarios (e.g. scheduled task) a console window would pop + * up for a split second. We abandon console support to avoid that, and we + * log error messages to the debugger using the OutputDebugString API. + * + * In UI mode we show a ChooseColor dialog and let the user pick the color + * while previewing the current selection on the mouse itself. + * + * For this we need a) a separate thread (i.e. previewing thread) to update + * the device and b) to define the color chooser's hook procedure (CCHOOKPROC) + * in order to get the color values while the user is selecting and before they + * press the ok button. + * + * We get RGB channel updates one-by-one in 3 consecutive WM_COMMAND(EN_UPDATE) + * messages in CCHOOKPROC, therefore it should be more perfomant not to trigger + * the preview thread (which would send a USB command to the mouse) on + * each of those (partial) updates. We store those updates in `CURRENT_RGB`. + * + * A full update is assumed to be when the WM_PAINT message is sent, at which + * point we update `RGB_TO_SET` to be picked up by the preview thread. + */ +static mut CURRENT_RGB: [u8; 3] = [0u8; 3]; +static RGB_TO_SET: Mutex> = Mutex::new(None); + + +/* + * Log messages to the debugger using OutputDebugString (only for command line + * invocation). Use DebugView by Mark Russinovich to view + */ +macro_rules! dbglog { + ($($args: tt)*) => { + unsafe { + let msg = format!($($args)*); + OutputDebugStringA(PCSTR::from_raw(msg.as_ptr())); + } + } +} + +macro_rules! dbgpanic { + ($($args: tt)*) => { + unsafe { + let msg = format!($($args)*); + OutputDebugStringA(PCSTR::from_raw(msg.as_ptr())); + panic!("{}", msg); + } + } +} + +fn main() { + + /* + * Command line mode if at least one argument + */ + let args: Vec = std::env::args().collect(); + + let parse_arg = |input: &str| -> RGB8 { + match rgb_from_hex(input) { + Ok(rgb) => rgb, + Err(e) => { dbgpanic!("argument '{}' should be in the \ + form [0x/#]RGB[h] or [0x/#]RRGGBB[h] where R, G, and B are hex \ + digits: {}", input, e); } + } + }; + + if args.len() > 1 { + let (color, wheel_color) = if args[1] == "--last" { + match Config::load() { + Some(cfg) => (cfg.color, cfg.wheel_color), + None => dbgpanic!("failed to load configuration; please specify \ + arguments manually") + } + } else { + (parse_arg(args[1].as_ref()), if args.len() > 2 { + Some(parse_arg(args[2].as_ref())) + } else { + None + }) + }; + + match set_color(color, wheel_color) { + Ok(msg) => dbglog!("{}", msg), + Err(e) => dbgpanic!("Failed to set color(s): {}", e) + } + return; + }; + + + /* + * no arguments; UI mode + */ + + // this will be the master signal to end the device preview thread + let keep_previewing = Arc::new(Mutex::new(true)); + + let preview_thread = { + + // make a copy of the master signal and loop on it + let keep_previewing = Arc::clone(&keep_previewing); + thread::spawn(move || { + + // save some resources by setting each color once + let mut last_set: Option = None; + + while *keep_previewing.lock().unwrap() { + + match *RGB_TO_SET.lock().unwrap() { + same if same == last_set => (), + None => (), + Some(rgb) => { + _ = preview_color(rgb, None); + last_set = Some(rgb); + }, + } + + // don't overkill; 10ms interval is smooth enough + thread::sleep(Duration::from_millis(10)); + } + // preview thread exit + + }) // return the thread handle + }; + + // set initial chooser UI color based on config (if any) + let cfg = Config::load(); + let initial = match cfg { + Some(ref cfg) => cfg.color, + None => RGB8::default() + }; + + // block waiting the user to choose + let chosen = ui_choose_color(initial); + + // make sure the thread has stopped previewing on the device + *keep_previewing.lock().unwrap() = false; + preview_thread.join().unwrap(); + + // set the final value based on user's selection + if chosen.is_some() { + _ = set_color(chosen.unwrap(), None); + } else if cfg.is_some() { + let cfgu = cfg.unwrap(); + _ = set_color(cfgu.color, cfgu.wheel_color); + } else { + _ = set_color(initial, None); + } +} + +/* + * Init and show ChooseColor dialog, blocking until the user dismisses it. + * In the meantime, preview colors by hooking it with a CCHOOKPROC. + */ +fn ui_choose_color(initial: RGB8) -> Option { + unsafe { + let mut initial_cr = COLORREF( + initial.r as u32 | + (initial.g as u32) << 8 | + (initial.b as u32) << 16); + + let mut cc = CHOOSECOLORA { + lStructSize: size_of::() as u32, + rgbResult: initial_cr, + lpCustColors: &mut initial_cr, + Flags: CC_FULLOPEN | CC_ANYCOLOR | CC_RGBINIT | CC_ENABLEHOOK | CC_PREVENTFULLOPEN, + lpfnHook: Some(cc_hook_proc), + lpTemplateName: PCSTR::null(), + ..Default::default() + }; + + let res = ChooseColorA(&mut cc).into(); + if res { + Some(RGB8{ + r: (cc.rgbResult.0 & 0xff) as u8, + g: ((cc.rgbResult.0 >> 8) & 0xff) as u8, + b: ((cc.rgbResult.0 >> 16) & 0xff) as u8, + }) + } else { + None + } + } +} + +/* + * std::ffi::CStr::from_bytes_until_nul() is atm nightly experimental API so + * we need this to convert a byte array with one or more null terminators in it + */ +unsafe fn u8sz_to_u8(s: &[u8]) -> u8 { + let str = CStr::from_ptr(s.as_ptr() as *const _).to_str().unwrap(); + str.parse::().unwrap() +} + +/* + * The CCHOOKPROC used for 2 things: 1) to center our orphan dialog and 2) + * to fetch color udpates before pressing Ok. + */ +unsafe extern "system" fn cc_hook_proc( + hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> usize { + match msg { + + WM_INITDIALOG => { + // center the color chooser on the desktop + let mut rc = RECT::default(); + let mut desktop_rc = RECT::default(); + if GetWindowRect(hwnd, &mut rc).into() && + GetClientRect(GetDesktopWindow(), &mut desktop_rc).into() { + rc.left = (desktop_rc.right/2) - ((rc.right - rc.left)/2); + rc.top = (desktop_rc.bottom/2) - ((rc.bottom - rc.top)/2); + SetWindowPos(hwnd, HWND(0), rc.left, rc.top, 0, 0, + SWP_NOZORDER | SWP_NOSIZE); + } + }, + + WM_COMMAND => { + // update one RGB channel + let cmd = (wparam.0 >> 16) as u32; + let ctrl_id = wparam.0 & 0xffff; + let ctrl_handle = HWND(lparam.0); + + // used WinId to get the textboxes' ids (0x2c2,3,4) + if cmd == EN_UPDATE && 0x2c2 <= ctrl_id && ctrl_id <= 0x2c4 { + let mut text = [0u8; 10]; + let len = GetWindowTextA(ctrl_handle, &mut text); + if 0 < len && len <= 3 { + CURRENT_RGB[ctrl_id - 0x2c2] = u8sz_to_u8(&text); + } + } + }, + + WM_PAINT => { + // commit the full RGB change + let mut rgb = RGB_TO_SET.lock().unwrap(); + *rgb = Some( + RGB8::new(CURRENT_RGB[0], CURRENT_RGB[1], CURRENT_RGB[2]) + ); + } + _ => () + } + 0 +} \ No newline at end of file diff --git a/lib/Cargo.toml b/lib/Cargo.toml new file mode 100644 index 0000000..27f15e6 --- /dev/null +++ b/lib/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "libdeathadder" +version = "0.1.0" +edition = "2021" + +[dependencies] +confy = "0.5.1" +serde = { version = "1.0.152", features = ["derive"] } +rgb = { version = "0.8.36", features = ["serde"] } +rusb = "0.9" \ No newline at end of file diff --git a/lib/src/lib.rs b/lib/src/lib.rs new file mode 100644 index 0000000..298bb60 --- /dev/null +++ b/lib/src/lib.rs @@ -0,0 +1,163 @@ +pub mod core { + use std::{num::ParseIntError, fmt, error, default::Default}; + use serde::{Serialize, Deserialize}; + use confy::{ConfyError}; + use rgb::{ + RGB8, FromSlice + }; + + #[derive(Debug, Serialize, Deserialize)] + pub struct Config { + pub color: RGB8, + pub wheel_color: Option, + } + + impl Config { + pub fn save(&self) -> Result<(), ConfyError> { + confy::store("deathadder", None, self) + } + + pub fn load() -> Option { + match confy::load("deathadder", None) { + Ok(cfg) => Some(cfg), + Err(_) => None + } + } + } + + impl Default for Config { + fn default() -> Self { + Self { + color: RGB8::new(0xAA, 0xAA, 0xAA), + wheel_color: None + } + } + } + + #[derive(Debug)] + pub enum ParseRGBError { + WrongLength(usize), + ParseHex(ParseIntError), + } + + impl fmt::Display for ParseRGBError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + ParseRGBError::WrongLength(len) => + write!(f, "excluding pre/suffixes, \ + string can only be of length 3 or 6 ({} given)", len), + ParseRGBError::ParseHex(ref pie) => + write!(f, "{}", pie), + } + } + } + + impl error::Error for ParseRGBError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match *self { + ParseRGBError::WrongLength(_) => None, + ParseRGBError::ParseHex(ref pie) => Some(pie), + } + } + } + + impl From for ParseRGBError { + fn from(err: ParseIntError) -> ParseRGBError { + ParseRGBError::ParseHex(err) + } + } + + pub fn rgb_from_hex(input: &str) -> Result { + let s = input + .trim_start_matches("0x") + .trim_start_matches("#") + .trim_end_matches("h"); + + match s.len() { + 3 => { + match s.chars() + .map(|c| u8::from_str_radix(format!("{}{}", c, c).as_str(), 16)) + .collect::, ParseIntError>>() { + Ok(res) => Ok(res.as_rgb()[0]), + Err(pie) => Err(ParseRGBError::from(pie)) + } + }, + 6 => { + match (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) + .collect::, ParseIntError>>() { + Ok(res) => Ok(res.as_rgb()[0]), + Err(pie) => Err(ParseRGBError::from(pie)) + } + }, + _ => { + Err(ParseRGBError::WrongLength(s.len())) + } + } + } +} + +pub mod v2 { + use std::{time::Duration}; + use rusb::{ + Context, UsbContext, + }; + use rgb::{ + RGB8 + }; + use crate::core::Config; + + pub fn preview_color(color: RGB8, wheel_color: Option) -> Result { + _set_color(color, wheel_color, false) + } + + pub fn set_color(color: RGB8, wheel_color: Option) -> Result { + _set_color(color, wheel_color, true) + } + + fn _set_color(color: RGB8, wheel_color: Option, save: bool) -> Result { + let vid = 0x1532; + let pid = 0x0084; + + let timeout = Duration::from_secs(1); + + // save regardless of USB result and fail silently + if save { + _ = Config {color, wheel_color}.save(); + } + + match Context::new() { + Ok(context) => match context.open_device_with_vid_pid(vid, pid) { + Some(handle) => { + + let mut packet: Vec = vec![ + // the start (no idea what they are) + 0x00, 0x1f, 0x00, 0x00, 0x00, 0x0b, 0x0f, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01, + + // wheel RGB (3B) | body RGB (3B) + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + + // the trailer (no idea what they are either) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00 + ]; + + packet.splice(16..19, color.iter()); + packet.splice(13..16, wheel_color.unwrap_or(color).iter()); + + match handle.write_control(0x21, 9, 0x300, 0, + &packet, timeout) { + Ok(len) => Ok(format!("written {} bytes", len)), + Err(e) => Err(format!("could not write ctrl transfer: {}", e)) + } + } + None => Err(format!("could not find device {:04x}:{:04x}", vid, pid)), + }, + Err(e) => Err(format!("could not initialize libusb: {}", e)), + } + } +} \ No newline at end of file