first commit

This commit is contained in:
Alexander Daichendt 2026-05-17 21:33:41 +02:00
commit 2a1252cc7a
69 changed files with 7559 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

25
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "razer-linux-desktop"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "razer_linux_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
libc = "0.2"
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,397 @@
fn decode_button_mapping(
profile: String,
button: String,
hypershift: bool,
data: &[u8],
) -> Result<ButtonMappingState, String> {
let fn_class = data.first().copied().unwrap_or(0);
let fn_len = data
.get(1)
.copied()
.unwrap_or(data.len().saturating_sub(2) as u8) as usize;
let payload_start = data.len().min(2);
let payload_end = (2 + fn_len.min(5)).min(data.len());
let fn_value = &data[payload_start..payload_end];
let decoded = match fn_class {
0x00 => Ok(("disabled".to_string(), json!({}))),
0x01 => Ok((
"mouse".to_string(),
json!({"fn": decode_fn_mouse(*fn_value.first().ok_or_else(|| "Mouse mapping payload too short".to_string())?)?, "double_click": false}),
)),
0x0b => Ok((
"mouse".to_string(),
json!({"fn": decode_fn_mouse(*fn_value.first().ok_or_else(|| "Mouse mapping payload too short".to_string())?)?, "double_click": true}),
)),
0x0e => Ok((
"mouse".to_string(),
json!({
"fn": decode_fn_mouse(*fn_value.first().ok_or_else(|| "Turbo mouse mapping payload too short".to_string())?)?,
"turbo": u16::from_be_bytes([
*fn_value.get(1).ok_or_else(|| "Turbo mouse mapping payload too short".to_string())?,
*fn_value.get(2).ok_or_else(|| "Turbo mouse mapping payload too short".to_string())?,
])
}),
)),
0x02 => Ok((
"keyboard".to_string(),
json!({
"modifier": decode_modifiers(*fn_value.first().ok_or_else(|| "Keyboard mapping payload too short".to_string())?),
"key": *fn_value.get(1).ok_or_else(|| "Keyboard mapping payload too short".to_string())?
}),
)),
0x0d => Ok((
"keyboard".to_string(),
json!({
"modifier": decode_modifiers(*fn_value.first().ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?),
"key": *fn_value.get(1).ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?,
"turbo": u16::from_be_bytes([
*fn_value.get(2).ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?,
*fn_value.get(3).ok_or_else(|| "Turbo keyboard mapping payload too short".to_string())?,
])
}),
)),
0x03 | 0x04 | 0x05 | 0x0f => Ok((
"macro".to_string(),
json!({
"mode": decode_macro_mode(fn_class)?,
"macro_id": u16::from_be_bytes([
*fn_value.first().ok_or_else(|| "Macro mapping payload too short".to_string())?,
*fn_value.get(1).ok_or_else(|| "Macro mapping payload too short".to_string())?,
]),
"times": if fn_class == 0x03 { fn_value.get(2).copied().unwrap_or(1) } else { 1 }
}),
)),
0x06 => decode_dpi_switch(fn_value).map(|payload| ("dpi_switch".to_string(), payload)),
0x07 => decode_profile_switch(fn_value).map(|payload| ("profile_switch".to_string(), payload)),
0x09 => Ok((
"system".to_string(),
json!({"fn": decode_system_flags(*fn_value.first().ok_or_else(|| "System mapping payload too short".to_string())?)}),
)),
0x0a => Ok((
"consumer".to_string(),
json!({"fn": u16::from_be_bytes([
*fn_value.first().ok_or_else(|| "Consumer mapping payload too short".to_string())?,
*fn_value.get(1).ok_or_else(|| "Consumer mapping payload too short".to_string())?,
])}),
)),
0x0c => Ok((
"hypershift_toggle".to_string(),
json!({"fn": *fn_value.first().ok_or_else(|| "Hypershift toggle payload too short".to_string())?}),
)),
0x12 => Ok((
"scroll_mode_toggle".to_string(),
json!({"fn": *fn_value.first().ok_or_else(|| "Scroll mode toggle payload too short".to_string())?}),
)),
_ => Ok(("custom".to_string(), json!({"fn_class": fn_class, "fn_value": fn_value}))),
};
let (category, payload) = decoded.unwrap_or_else(|_| {
(
"custom".to_string(),
json!({"fn_class": fn_class, "fn_value": fn_value}),
)
});
Ok(ButtonMappingState {
profile,
button,
hypershift,
category,
payload,
})
}
fn encode_button_mapping(mapping: &ButtonMappingState) -> Result<[u8; 7], String> {
let mut out = [0u8; 7];
let payload = mapping
.payload
.as_object()
.ok_or_else(|| "Button mapping payload must be an object".to_string())?;
let (fn_class, fn_value) = match mapping.category.as_str() {
"disabled" => (0x00, vec![]),
"mouse" => {
let fn_button = fn_mouse_value(get_string(payload, "fn")?)?;
if payload.get("turbo").is_some() && !payload["turbo"].is_null() {
let turbo = get_u16(payload, "turbo")?;
(0x0e, vec![fn_button, (turbo >> 8) as u8, turbo as u8])
} else if payload
.get("double_click")
.and_then(Value::as_bool)
.unwrap_or(false)
{
(0x0b, vec![fn_button])
} else {
(0x01, vec![fn_button])
}
}
"keyboard" => {
let modifier = encode_modifiers(payload.get("modifier"))?;
let key = get_u8(payload, "key")?;
if payload.get("turbo").is_some() && !payload["turbo"].is_null() {
let turbo = get_u16(payload, "turbo")?;
(0x0d, vec![modifier, key, (turbo >> 8) as u8, turbo as u8])
} else {
(0x02, vec![modifier, key])
}
}
"macro" => {
let fn_class = encode_macro_mode(get_string(payload, "mode")?)?;
let macro_id = get_u16(payload, "macro_id")?;
let mut value = vec![(macro_id >> 8) as u8, macro_id as u8];
if fn_class == 0x03 {
value.push(get_u8(payload, "times").unwrap_or(1));
}
(fn_class, value)
}
"dpi_switch" => encode_dpi_switch(payload)?,
"profile_switch" => encode_profile_switch(payload)?,
"system" => (0x09, vec![encode_system_flags(payload.get("fn"))?]),
"consumer" => {
let code = get_u16(payload, "fn")?;
(0x0a, vec![(code >> 8) as u8, code as u8])
}
"hypershift_toggle" => (0x0c, vec![get_u8(payload, "fn").unwrap_or(1)]),
"scroll_mode_toggle" => (0x12, vec![get_u8(payload, "fn").unwrap_or(1)]),
"custom" => {
let fn_class = get_u8(payload, "fn_class")?;
let fn_value = payload
.get("fn_value")
.and_then(Value::as_array)
.ok_or_else(|| "custom.fn_value must be an array".to_string())?
.iter()
.map(|value| {
value
.as_u64()
.filter(|value| *value <= 255)
.map(|value| value as u8)
.ok_or_else(|| "custom.fn_value must contain bytes".to_string())
})
.collect::<Result<Vec<_>, _>>()?;
(fn_class, fn_value)
}
other => return Err(format!("Unsupported button mapping category '{other}'")),
};
if fn_value.len() > 5 {
return Err("Button mapping payload is too large".to_string());
}
out[0] = fn_class;
out[1] = fn_value.len() as u8;
out[2..2 + fn_value.len()].copy_from_slice(&fn_value);
Ok(out)
}
fn decode_fn_mouse(value: u8) -> Result<&'static str, String> {
match value {
0x01 => Ok("left"),
0x02 => Ok("right"),
0x03 => Ok("middle"),
0x04 => Ok("backward"),
0x05 => Ok("forward"),
0x09 => Ok("wheel_up"),
0x0a => Ok("wheel_down"),
0x68 => Ok("wheel_left"),
0x69 => Ok("wheel_right"),
_ => Err(format!("Unknown mouse function value {value}")),
}
}
fn decode_modifiers(value: u8) -> Vec<&'static str> {
let mut out = Vec::new();
for (flag, name) in [
(0x01, "left_control"),
(0x02, "left_shift"),
(0x04, "left_alt"),
(0x08, "left_gui"),
(0x10, "right_control"),
(0x20, "right_shift"),
(0x40, "right_alt"),
(0x80, "right_gui"),
] {
if value & flag != 0 {
out.push(name);
}
}
out
}
fn encode_modifiers(value: Option<&Value>) -> Result<u8, String> {
let mut out = 0u8;
let Some(values) = value else { return Ok(out) };
let Some(values) = values.as_array() else { return Err("modifier must be an array".to_string()) };
for item in values {
out |= match item.as_str().ok_or_else(|| "modifier entries must be strings".to_string())? {
"left_control" => 0x01,
"left_shift" => 0x02,
"left_alt" => 0x04,
"left_gui" => 0x08,
"right_control" => 0x10,
"right_shift" => 0x20,
"right_alt" => 0x40,
"right_gui" => 0x80,
other => return Err(format!("Unknown modifier '{other}'")),
};
}
Ok(out)
}
fn decode_macro_mode(value: u8) -> Result<&'static str, String> {
match value {
0x03 => Ok("macro_fixed"),
0x04 => Ok("macro_hold"),
0x05 => Ok("macro_toggle"),
0x0f => Ok("macro_sequence"),
_ => Err(format!("Unknown macro mode {value}")),
}
}
fn encode_macro_mode(value: &str) -> Result<u8, String> {
match value {
"macro_fixed" => Ok(0x03),
"macro_hold" => Ok(0x04),
"macro_toggle" => Ok(0x05),
"macro_sequence" => Ok(0x0f),
_ => Err(format!("Unknown macro mode '{value}'")),
}
}
fn decode_dpi_switch(value: &[u8]) -> Result<Value, String> {
Ok(match value.len() {
1 => json!({ "fn": decode_dpi_switch_op(value[0])? }),
2 => json!({ "fn": decode_dpi_switch_op(value[0])?, "stage": value[1] }),
5 => json!({ "fn": decode_dpi_switch_op(value[0])?, "dpi": [u16::from_be_bytes([value[1], value[2]]), u16::from_be_bytes([value[3], value[4]])] }),
_ => return Err("Unsupported DPI switch payload".to_string()),
})
}
fn encode_dpi_switch(payload: &serde_json::Map<String, Value>) -> Result<(u8, Vec<u8>), String> {
let op = get_string(payload, "fn")?;
let op_value = encode_dpi_switch_op(op)?;
Ok(match op {
"fixed" => (0x06, vec![op_value, get_u8(payload, "stage")?]),
"aim" => {
let dpi = payload
.get("dpi")
.and_then(Value::as_array)
.ok_or_else(|| "dpi_switch.dpi must be an array".to_string())?;
let x = dpi.first().and_then(Value::as_u64).ok_or_else(|| "dpi_switch.dpi[0] missing".to_string())? as u16;
let y = dpi.get(1).and_then(Value::as_u64).ok_or_else(|| "dpi_switch.dpi[1] missing".to_string())? as u16;
(0x06, vec![op_value, (x >> 8) as u8, x as u8, (y >> 8) as u8, y as u8])
}
_ => (0x06, vec![op_value]),
})
}
fn decode_dpi_switch_op(value: u8) -> Result<&'static str, String> {
match value {
0x01 => Ok("next"),
0x02 => Ok("prev"),
0x03 => Ok("fixed"),
0x05 => Ok("aim"),
0x06 => Ok("next_loop"),
0x07 => Ok("prev_loop"),
_ => Err(format!("Unknown DPI switch op {value}")),
}
}
fn encode_dpi_switch_op(value: &str) -> Result<u8, String> {
match value {
"next" => Ok(0x01),
"prev" => Ok(0x02),
"fixed" => Ok(0x03),
"aim" => Ok(0x05),
"next_loop" => Ok(0x06),
"prev_loop" => Ok(0x07),
_ => Err(format!("Unknown DPI switch op '{value}'")),
}
}
fn decode_profile_switch(value: &[u8]) -> Result<Value, String> {
Ok(match value.len() {
1 => json!({ "fn": decode_profile_switch_op(value[0])? }),
2 => json!({ "fn": decode_profile_switch_op(value[0])?, "profile": profile_name(value[1])? }),
_ => return Err("Unsupported profile switch payload".to_string()),
})
}
fn encode_profile_switch(payload: &serde_json::Map<String, Value>) -> Result<(u8, Vec<u8>), String> {
let op = get_string(payload, "fn")?;
let op_value = encode_profile_switch_op(op)?;
Ok(if op == "fixed" {
(0x07, vec![op_value, profile_value(get_string(payload, "profile")?)?])
} else {
(0x07, vec![op_value])
})
}
fn decode_profile_switch_op(value: u8) -> Result<&'static str, String> {
match value {
0x01 => Ok("next"),
0x02 => Ok("prev"),
0x03 => Ok("fixed"),
0x04 => Ok("next_loop"),
0x05 => Ok("prev_loop"),
_ => Err(format!("Unknown profile switch op {value}")),
}
}
fn encode_profile_switch_op(value: &str) -> Result<u8, String> {
match value {
"next" => Ok(0x01),
"prev" => Ok(0x02),
"fixed" => Ok(0x03),
"next_loop" => Ok(0x04),
"prev_loop" => Ok(0x05),
_ => Err(format!("Unknown profile switch op '{value}'")),
}
}
fn decode_system_flags(value: u8) -> Vec<&'static str> {
let mut out = Vec::new();
for (flag, name) in [(0x01, "power_down"), (0x02, "sleep"), (0x04, "wake_up")] {
if value & flag != 0 {
out.push(name);
}
}
out
}
fn encode_system_flags(value: Option<&Value>) -> Result<u8, String> {
let Some(values) = value else { return Ok(0) };
let Some(values) = values.as_array() else { return Err("system.fn must be an array".to_string()) };
let mut out = 0u8;
for item in values {
out |= match item.as_str().ok_or_else(|| "system.fn entries must be strings".to_string())? {
"power_down" => 0x01,
"sleep" => 0x02,
"wake_up" => 0x04,
other => return Err(format!("Unknown system function '{other}'")),
};
}
Ok(out)
}
fn get_string<'a>(payload: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a str, String> {
payload
.get(key)
.and_then(Value::as_str)
.ok_or_else(|| format!("Missing string field '{key}'"))
}
fn get_u8(payload: &serde_json::Map<String, Value>, key: &str) -> Result<u8, String> {
payload
.get(key)
.and_then(Value::as_u64)
.filter(|value| *value <= 255)
.map(|value| value as u8)
.ok_or_else(|| format!("Missing byte field '{key}'"))
}
fn get_u16(payload: &serde_json::Map<String, Value>, key: &str) -> Result<u16, String> {
payload
.get(key)
.and_then(Value::as_u64)
.filter(|value| *value <= 65535)
.map(|value| value as u16)
.ok_or_else(|| format!("Missing u16 field '{key}'"))
}

View file

@ -0,0 +1,440 @@
#[tauri::command]
fn list_supported_devices() -> Result<Vec<DeviceSummary>, String> {
discover_devices().map_err(|err| err.to_string())
}
#[tauri::command]
fn connect_device(path: String, state: tauri::State<'_, AppState>) -> Result<DeviceState, String> {
let discovered = discover_devices().map_err(|err| err.to_string())?;
let selected = discovered
.iter()
.find(|device| device.path == path)
.cloned()
.ok_or_else(|| format!("No supported Razer device found at {path}"))?;
let mut candidates = connection_candidates(&discovered, &selected);
let mut failures = Vec::new();
for summary in candidates.drain(..) {
match try_connect_summary(summary.clone(), state.logs.clone()) {
Ok((device, snapshot)) => {
*state
.connected
.lock()
.map_err(|_| "Device state lock was poisoned".to_string())? = Some(device);
return Ok(snapshot);
}
Err(error) => failures.push(format!("{}: {error}", summary.path)),
}
}
Err(format!(
"Could not talk to the selected Razer device on any matching hidraw node. Tried: {}",
failures.join("; ")
))
}
#[tauri::command]
fn refresh_device_state(
profile: String,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
with_device(&state, |device| device.snapshot(&profile))
}
#[tauri::command]
fn set_scroll_mode(
profile: String,
mode: String,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
with_device(&state, |device| {
device.set_scroll_mode(profile_value(&profile)?, scroll_mode_value(&mode)?)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn set_scroll_acceleration(
profile: String,
enabled: bool,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
with_device(&state, |device| {
device.set_bool_command(0x0216, profile_value(&profile)?, enabled)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn set_scroll_smart_reel(
profile: String,
enabled: bool,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
with_device(&state, |device| {
device.set_bool_command(0x0217, profile_value(&profile)?, enabled)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn set_polling_rate(
profile: String,
polling_rate_ms: u8,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
if polling_rate_ms == 0 {
return Err("Polling rate delay must be at least 1 ms".to_string());
}
with_device(&state, |device| {
device.sr_with(0x000e, &[profile_value(&profile)?, polling_rate_ms], 0)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn set_dpi_xy(
profile: String,
x: u16,
y: u16,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
if !(100..=25600).contains(&x) || !(100..=25600).contains(&y) {
return Err("DPI must be between 100 and 25600".to_string());
}
with_device(&state, |device| {
let mut args = Vec::with_capacity(7);
args.push(profile_value(&profile)?);
args.extend_from_slice(&x.to_be_bytes());
args.extend_from_slice(&y.to_be_bytes());
args.extend_from_slice(&[0, 0]);
device.sr_with(0x0405, &args, 0)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn set_dpi_stages(
profile: String,
dpi_stages: Vec<[u16; 2]>,
active_stage: u8,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
if dpi_stages.is_empty() || dpi_stages.len() > 5 {
return Err("DPI stage count must be between 1 and 5".to_string());
}
if active_stage == 0 || usize::from(active_stage) > dpi_stages.len() {
return Err("Active DPI stage must point to an existing stage".to_string());
}
if dpi_stages
.iter()
.flatten()
.any(|dpi| !(100..=25600).contains(dpi))
{
return Err("All DPI stages must be between 100 and 25600".to_string());
}
with_device(&state, |device| {
let mut args = Vec::with_capacity(38);
args.push(profile_value(&profile)?);
args.push(active_stage);
args.push(dpi_stages.len() as u8);
for (index, [x, y]) in dpi_stages.iter().copied().enumerate() {
args.push(index as u8);
args.extend_from_slice(&x.to_be_bytes());
args.extend_from_slice(&y.to_be_bytes());
args.extend_from_slice(&[0, 0]);
}
while args.len() < 38 {
args.extend_from_slice(&[0; 7]);
}
device.sr_with(0x0406, &args, 0)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn create_profile(
profile: String,
current_profile: String,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
let profile = profile_value(&profile)?;
with_device(&state, |device| {
device.sr_with(0x0502, &[profile], 0)?;
device.snapshot(&current_profile)
})
}
#[tauri::command]
fn delete_profile(
profile: String,
next_profile: String,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
let profile = profile_value(&profile)?;
with_device(&state, |device| {
device.sr_with(0x0503, &[profile], 0)?;
device.snapshot(&next_profile)
})
}
#[tauri::command]
fn get_led_state(profile: String, state: tauri::State<'_, AppState>) -> Result<LedState, String> {
with_device(&state, |device| device.led_snapshot(&profile))
}
#[tauri::command]
fn set_led_effect(
profile: String,
region: String,
effect: String,
mode: u8,
speed: u8,
colors: Vec<[u8; 3]>,
state: tauri::State<'_, AppState>,
) -> Result<LedState, String> {
with_device(&state, |device| {
let profile = profile_value(&profile)?;
let region = led_region_value(&region)?;
let effect = led_effect_value(&effect)?;
let color_count = colors.len();
if color_count > 2 {
return Err("At most 2 LED colors are supported in this port".to_string());
}
let mut args = Vec::with_capacity(6 + color_count * 3);
args.push(profile);
args.push(region);
args.push(effect);
args.push(mode);
args.push(speed);
args.push(color_count as u8);
for color in colors {
args.extend_from_slice(&color);
}
device.sr_with(0x0f02, &args, 0)?;
device.led_snapshot(&profile_name(profile)?.to_string())
})
}
#[tauri::command]
fn set_led_brightness(
profile: String,
region: String,
brightness: u8,
state: tauri::State<'_, AppState>,
) -> Result<LedState, String> {
with_device(&state, |device| {
let profile = profile_value(&profile)?;
let region = led_region_value(&region)?;
device.sr_with(0x0f04, &[profile, region, brightness], 0)?;
device.led_snapshot(&profile_name(profile)?.to_string())
})
}
#[tauri::command]
fn apply_led_to_all_regions(
profile: String,
region_state: LedRegionState,
state: tauri::State<'_, AppState>,
) -> Result<LedState, String> {
with_device(&state, |device| {
let profile_value = profile_value(&profile)?;
let effect_value = led_effect_value(&region_state.effect)?;
for region in ["wheel", "logo", "strip"] {
let region_value = led_region_value(region)?;
let mut effect_args = Vec::with_capacity(6 + region_state.colors.len() * 3);
effect_args.push(profile_value);
effect_args.push(region_value);
effect_args.push(effect_value);
effect_args.push(region_state.mode);
effect_args.push(region_state.speed);
effect_args.push(region_state.colors.len() as u8);
for color in &region_state.colors {
effect_args.extend_from_slice(color);
}
device.sr_with(0x0f02, &effect_args, 0)?;
device.sr_with(0x0f04, &[profile_value, region_value, region_state.brightness], 0)?;
}
device.led_snapshot(&profile)
})
}
#[tauri::command]
fn get_button_mapping(
profile: String,
button: String,
hypershift: bool,
state: tauri::State<'_, AppState>,
) -> Result<ButtonMappingState, String> {
with_device(&state, |device| device.get_button_mapping_state(&profile, &button, hypershift))
}
#[tauri::command]
fn set_button_mapping(
mapping: ButtonMappingState,
state: tauri::State<'_, AppState>,
) -> Result<ButtonMappingState, String> {
with_device(&state, |device| device.set_button_mapping_state(mapping))
}
#[tauri::command]
fn export_profile_config(
profile: String,
state: tauri::State<'_, AppState>,
) -> Result<ProfileConfigBundle, String> {
with_device(&state, |device| device.export_profile_config(&profile))
}
#[tauri::command]
fn import_profile_config(
profile: String,
bundle: ProfileConfigBundle,
state: tauri::State<'_, AppState>,
) -> Result<DeviceState, String> {
with_device(&state, |device| {
device.import_profile_config(&profile, bundle)?;
device.snapshot(&profile)
})
}
#[tauri::command]
fn list_macros(state: tauri::State<'_, AppState>) -> Result<Vec<u16>, String> {
with_device(&state, |device| device.get_macro_list())
}
#[tauri::command]
fn get_macro_definition(
macro_id: u16,
state: tauri::State<'_, AppState>,
) -> Result<MacroDefinition, String> {
with_device(&state, |device| device.get_macro_definition(macro_id))
}
#[tauri::command]
fn set_macro_definition(
macro_id: u16,
definition: MacroDefinition,
state: tauri::State<'_, AppState>,
) -> Result<Vec<u16>, String> {
with_device(&state, |device| {
device.set_macro_definition(macro_id, definition)?;
device.get_macro_list()
})
}
#[tauri::command]
fn delete_macro(macro_id: u16, state: tauri::State<'_, AppState>) -> Result<Vec<u16>, String> {
with_device(&state, |device| {
device.delete_macro(macro_id)?;
device.get_macro_list()
})
}
#[tauri::command]
fn reset_macro_flash(state: tauri::State<'_, AppState>) -> Result<Vec<u16>, String> {
with_device(&state, |device| {
device.reset_macro_flash()?;
device.get_macro_list()
})
}
#[tauri::command]
fn get_debug_logs(state: tauri::State<'_, AppState>) -> Result<Vec<DebugLogEntry>, String> {
state
.logs
.lock()
.map(|logs| logs.clone())
.map_err(|_| "Debug log lock was poisoned".to_string())
}
#[tauri::command]
fn clear_debug_logs(state: tauri::State<'_, AppState>) -> Result<(), String> {
state
.logs
.lock()
.map(|mut logs| logs.clear())
.map_err(|_| "Debug log lock was poisoned".to_string())
}
#[tauri::command]
fn get_sensor_state(state: tauri::State<'_, AppState>) -> Result<SensorState, String> {
with_device(&state, |device| device.sensor_snapshot())
}
#[tauri::command]
fn set_sensor_lift_mode(
lift_mode: String,
state: tauri::State<'_, AppState>,
) -> Result<SensorState, String> {
with_device(&state, |device| {
device.set_sensor_lift_mode(&lift_mode)?;
device.sensor_snapshot()
})
}
#[tauri::command]
fn start_sensor_calibration(state: tauri::State<'_, AppState>) -> Result<SensorState, String> {
with_device(&state, |device| {
device.start_sensor_calibration()?;
device.sensor_snapshot()
})
}
#[tauri::command]
fn stop_sensor_calibration(state: tauri::State<'_, AppState>) -> Result<SensorState, String> {
with_device(&state, |device| {
device.stop_sensor_calibration()?;
device.sensor_snapshot()
})
}
#[tauri::command]
fn set_sensor_params(
param_a: Vec<u8>,
param_b: Vec<u8>,
state: tauri::State<'_, AppState>,
) -> Result<SensorState, String> {
with_device(&state, |device| {
device.set_sensor_params(&param_a, &param_b)?;
device.sensor_snapshot()
})
}
#[tauri::command]
fn get_info_state(state: tauri::State<'_, AppState>) -> Result<InfoState, String> {
with_device(&state, |device| device.info_snapshot())
}
#[tauri::command]
fn send_raw_command(
command: u16,
args: Vec<u8>,
state: tauri::State<'_, AppState>,
) -> Result<Vec<u8>, String> {
with_device(&state, |device| Ok(device.sr_with(command, &args, 0)?.to_vec()))
}
#[tauri::command]
fn reset_flash(state: tauri::State<'_, AppState>) -> Result<InfoState, String> {
with_device(&state, |device| {
device.reset_macro_flash()?;
device.info_snapshot()
})
}
fn with_device<T>(
state: &tauri::State<'_, AppState>,
f: impl FnOnce(&mut ConnectedDevice) -> Result<T, String>,
) -> Result<T, String> {
let mut guard = state
.connected
.lock()
.map_err(|_| "Device state lock was poisoned".to_string())?;
let device = guard
.as_mut()
.ok_or_else(|| "No device is connected".to_string())?;
f(device)
}

View file

@ -0,0 +1,4 @@
include!("device_parts/core.rs");
include!("device_parts/profile_led_button.rs");
include!("device_parts/macro.rs");
include!("device_parts/sensor_io.rs");

View file

@ -0,0 +1,196 @@
impl ConnectedDevice {
fn snapshot(&mut self, profile: &str) -> Result<DeviceState, String> {
let profile_value = profile_value(profile)?;
let serial = self.get_serial()?;
let firmware = self.get_firmware_version()?;
let profiles = self
.get_profile_list()
.unwrap_or_else(|_| vec!["direct".to_string()]);
let basic = BasicSettings {
profile: profile.to_string(),
scroll_mode: self.get_scroll_mode(profile_value)?,
scroll_acceleration: self.get_bool_command(0x0296, profile_value)?,
scroll_smart_reel: self.get_bool_command(0x0297, profile_value)?,
polling_rate_ms: self.get_polling_rate(profile_value)?,
dpi_xy: self.get_dpi_xy(profile_value)?,
dpi_stages: self.get_dpi_stages(profile_value)?.0,
active_dpi_stage: self.get_dpi_stages(profile_value)?.1,
};
Ok(DeviceState {
device: self.summary.clone(),
serial,
firmware,
profiles,
basic,
})
}
fn get_serial(&mut self) -> Result<String, String> {
let response = self.sr_with(0x0082, &[0; 16], 0)?;
let serial = response[..16]
.split(|byte| *byte == 0)
.next()
.unwrap_or_default();
Ok(String::from_utf8_lossy(serial).to_string())
}
fn get_firmware_version(&mut self) -> Result<String, String> {
let response = self.sr_with(0x0081, &[0; 4], 0)?;
Ok(format!(
"{}.{}.{}.{}",
response[0], response[1], response[2], response[3]
))
}
fn get_profile_list(&mut self) -> Result<Vec<String>, String> {
let count = self.sr_with(0x0580, &[0], 0)?[0] as usize;
let mut args = vec![0; count + 1];
let response = self.sr_with(0x0581, &args, 0)?;
args.clear();
let mut profiles = vec!["direct".to_string()];
for profile in response.iter().skip(1).take(count) {
if let Ok(name) = profile_name(*profile) {
if !profiles.iter().any(|existing| existing == name) {
profiles.push(name.to_string());
}
}
}
Ok(profiles)
}
fn get_scroll_mode(&mut self, profile: u8) -> Result<String, String> {
let response = self.sr_with(0x0294, &[profile, 0], 0)?;
match response[1] {
0 => Ok("tactile".to_string()),
1 => Ok("freespin".to_string()),
value => Err(format!("Unknown scroll mode value {value}")),
}
}
fn set_scroll_mode(&mut self, profile: u8, mode: u8) -> Result<(), String> {
self.sr_with(0x0214, &[profile, mode], 0).map(|_| ())
}
fn get_bool_command(&mut self, command: u16, profile: u8) -> Result<bool, String> {
Ok(self.sr_with(command, &[profile, 0], 0)?[1] != 0)
}
fn set_bool_command(&mut self, command: u16, profile: u8, enabled: bool) -> Result<(), String> {
self.sr_with(command, &[profile, u8::from(enabled)], 0)
.map(|_| ())
}
fn get_polling_rate(&mut self, profile: u8) -> Result<u8, String> {
Ok(self.sr_with(0x008e, &[profile, 0], 0)?[1])
}
fn get_dpi_xy(&mut self, profile: u8) -> Result<[u16; 2], String> {
let response = self.sr_with(0x0485, &[profile, 0, 0, 0, 0, 0, 0], 0)?;
Ok([
u16::from_be_bytes([response[1], response[2]]),
u16::from_be_bytes([response[3], response[4]]),
])
}
fn get_dpi_stages(&mut self, profile: u8) -> Result<(Vec<[u16; 2]>, u8), String> {
let mut args = vec![0; 38];
args[0] = profile;
let response = self.sr_with(0x0486, &args, 0)?;
let active_stage = response[1];
let stage_count = response[2].min(5) as usize;
let stages = (0..stage_count)
.map(|index| {
let offset = 3 + (index * 7);
[
u16::from_be_bytes([response[offset + 1], response[offset + 2]]),
u16::from_be_bytes([response[offset + 3], response[offset + 4]]),
]
})
.collect();
Ok((stages, active_stage))
}
fn sr_with(&mut self, command: u16, args: &[u8], wait_power: u8) -> Result<[u8; 80], String> {
if args.len() > 80 {
return Err("Razer reports support at most 80 argument bytes".to_string());
}
let mut report = [0u8; 90];
report[0] = 0;
report[1] = 0x1f;
report[4] = 0;
report[5] = args.len() as u8;
report[6] = (command >> 8) as u8;
report[7] = command as u8;
report[8..8 + args.len()].copy_from_slice(args);
report[88] = report[2..88].iter().fold(0, |crc, byte| crc ^ byte);
self.append_log(
"tx",
format!("cmd=0x{command:04x} wait_power={wait_power} report={}", bytes_to_hex_compact(&report)),
);
if let Err(error) = self.send_feature_report(&report) {
self.append_log("error", format!("send cmd=0x{command:04x} failed: {error}"));
return Err(error);
}
for attempt in 0..(15 * (usize::from(wait_power) + 1)) {
sleep(Duration::from_millis(10 * (attempt as u64 + 1)));
let response = match self.get_feature_report() {
Ok(response) => response,
Err(error) => {
self.append_log("error", format!("recv cmd=0x{command:04x} failed: {error}"));
return Err(error);
}
};
self.append_log(
"rx",
format!("cmd=0x{command:04x} attempt={} report={}", attempt + 1, bytes_to_hex_compact(&response)),
);
if response[6] != report[6] || response[7] != report[7] {
self.append_log(
"warn",
format!(
"cmd=0x{command:04x} got different reply command=0x{:02x}{:02x}",
response[6], response[7]
),
);
return Err(
"Device replied to a different command; close other software using it"
.to_string(),
);
}
match response[0] {
2 => {
let mut arguments = [0u8; 80];
arguments.copy_from_slice(&response[8..88]);
self.append_log("info", format!("cmd=0x{command:04x} completed"));
return Ok(arguments);
}
1 => continue,
3 => {
self.append_log("error", format!("cmd=0x{command:04x} failure status"));
return Err("Device reported command failure".to_string());
}
4 => {
self.append_log("error", format!("cmd=0x{command:04x} timeout status"));
return Err("Device reported command timeout".to_string());
}
5 => {
self.append_log("warn", format!("cmd=0x{command:04x} unsupported"));
return Err("Device does not support this command".to_string());
}
status => {
self.append_log("error", format!("cmd=0x{command:04x} unexpected status {status}"));
return Err(format!("Device returned unexpected status {status}"));
}
}
}
self.append_log("error", format!("cmd=0x{command:04x} timed out waiting for response"));
Err("Timed out waiting for device response".to_string())
}
}

View file

@ -0,0 +1,157 @@
impl ConnectedDevice {
fn get_macro_count(&mut self) -> Result<u16, String> {
Ok(u16::from_be_bytes(self.sr_with(0x0680, &[0, 0], 0)?[..2].try_into().unwrap()))
}
fn get_macro_list(&mut self) -> Result<Vec<u16>, String> {
let mut macros = Vec::new();
let total = usize::from(self.get_macro_count()?);
while macros.len() < total {
let offset = u16::try_from(macros.len()).map_err(|_| "Macro list offset overflow".to_string())?;
let mut args = Vec::with_capacity(68);
args.extend_from_slice(&offset.to_be_bytes());
args.extend_from_slice(&0u16.to_be_bytes());
args.extend_from_slice(&[0; 64]);
let response = self.sr_with(0x068b, &args, 0)?;
let size = u16::from_be_bytes([response[0], response[1]]) as usize;
let take = size.saturating_sub(macros.len()).min(32);
for index in 0..take {
let offset = 2 + index * 2;
macros.push(u16::from_be_bytes([response[offset], response[offset + 1]]));
}
if take == 0 {
break;
}
}
Ok(macros)
}
fn get_macro_info(&mut self, macro_id: u16) -> Result<Vec<u8>, String> {
let mut data = Vec::new();
loop {
let mut args = Vec::with_capacity(70);
args.extend_from_slice(&macro_id.to_be_bytes());
args.extend_from_slice(&(data.len() as u16).to_be_bytes());
args.extend_from_slice(&0u16.to_be_bytes());
args.extend_from_slice(&[0; 64]);
let response = self.sr_with(0x068c, &args, 1)?;
let total_size = u16::from_be_bytes([response[2], response[3]]) as usize;
let chunk = &response[4..68];
let remaining = total_size.saturating_sub(data.len());
let take = remaining.min(chunk.len());
data.extend_from_slice(&chunk[..take]);
if data.len() >= total_size {
break;
}
}
Ok(data)
}
fn set_macro_info(&mut self, macro_id: u16, data: &[u8]) -> Result<(), String> {
let total_size = u16::try_from(data.len()).map_err(|_| "Macro info is too large to write".to_string())?;
let mut offset = 0usize;
while offset < data.len() {
let chunk = &data[offset..(offset + 64).min(data.len())];
let mut args = Vec::with_capacity(6 + chunk.len());
args.extend_from_slice(&macro_id.to_be_bytes());
args.extend_from_slice(&(offset as u16).to_be_bytes());
args.extend_from_slice(&total_size.to_be_bytes());
args.extend_from_slice(chunk);
self.sr_with(0x060c, &args, 1)?;
offset += chunk.len();
}
Ok(())
}
fn get_macro_size(&mut self, macro_id: u16) -> Result<u32, String> {
let mut args = Vec::with_capacity(6);
args.extend_from_slice(&macro_id.to_be_bytes());
args.extend_from_slice(&[0; 4]);
let response = self.sr_with(0x0688, &args, 0)?;
Ok(u32::from_be_bytes([response[2], response[3], response[4], response[5]]))
}
fn set_macro_size(&mut self, macro_id: u16, size: u32) -> Result<(), String> {
let mut args = Vec::with_capacity(6);
args.extend_from_slice(&macro_id.to_be_bytes());
args.extend_from_slice(&size.to_be_bytes());
self.sr_with(0x0608, &args, 0).map(|_| ())
}
fn get_macro_function(&mut self, macro_id: u16) -> Result<Vec<u8>, String> {
let size = self.get_macro_size(macro_id)? as usize;
let mut data = Vec::with_capacity(size);
while data.len() < size {
let offset = u32::try_from(data.len()).map_err(|_| "Macro function offset overflow".to_string())?;
let mut args = Vec::with_capacity(71);
args.extend_from_slice(&macro_id.to_be_bytes());
args.extend_from_slice(&offset.to_be_bytes());
args.push(64);
args.extend_from_slice(&[0; 64]);
let response = self.sr_with(0x0689, &args, 1)?;
let chunk = &response[..64.min(size - data.len())];
data.extend_from_slice(chunk);
}
Ok(data)
}
fn set_macro_function_bytes(&mut self, macro_id: u16, data: &[u8]) -> Result<(), String> {
let total_size = u32::try_from(data.len()).map_err(|_| "Macro function is too large to write".to_string())?;
self.set_macro_size(macro_id, total_size)?;
let mut offset = 0usize;
while offset < data.len() {
let chunk = &data[offset..(offset + 64).min(data.len())];
let mut args = Vec::with_capacity(7 + chunk.len());
args.extend_from_slice(&macro_id.to_be_bytes());
args.extend_from_slice(&(offset as u32).to_be_bytes());
args.push(chunk.len() as u8);
args.extend_from_slice(chunk);
self.sr_with(0x0609, &args, 1)?;
offset += chunk.len();
}
Ok(())
}
fn get_macro_definition(&mut self, macro_id: u16) -> Result<MacroDefinition, String> {
let macro_info = self.get_macro_info(macro_id).ok();
let macro_function = self.get_macro_function(macro_id)?;
Ok(MacroDefinition {
macro_id,
macro_info_hex: macro_info.map(|bytes| bytes_to_hex_compact(&bytes)),
operations: decode_macro_operations(&macro_function)?,
})
}
fn set_macro_definition(
&mut self,
macro_id: u16,
definition: MacroDefinition,
) -> Result<(), String> {
let operation_bytes = encode_macro_operations(&definition.operations)?;
self.delete_macro(macro_id).ok();
self.set_macro_function_bytes(macro_id, &operation_bytes)?;
if let Some(info_hex) = definition.macro_info_hex {
self.set_macro_info(macro_id, &parse_hex_bytes(&info_hex)?)?;
}
Ok(())
}
fn delete_macro(&mut self, macro_id: u16) -> Result<(), String> {
self.sr_with(0x0603, &macro_id.to_be_bytes(), 0).map(|_| ())
}
fn reset_macro_flash(&mut self) -> Result<(), String> {
let request = [0x00, 0x00, 0x00, 0x02, 0x00, 0x00];
self.sr_with(0x060a, &request, 0)?;
for _ in 0..20 {
let response = self.sr_with(0x068a, &request, 0)?;
if response[..6] == [0x00, 0x00, 0x02, 0x02, 0x00, 0x00] {
return Ok(());
}
sleep(Duration::from_millis(500));
}
Err("Resetting macro flash took too long".to_string())
}
}

View file

@ -0,0 +1,226 @@
impl ConnectedDevice {
fn led_snapshot(&mut self, profile: &str) -> Result<LedState, String> {
let profile_value = profile_value(profile)?;
let mut regions = Vec::new();
for (region_name, region_value) in [("wheel", 0x01), ("logo", 0x04), ("strip", 0x0a)] {
let (effect, mode, speed, colors) = self.get_led_effect(profile_value, region_value)?;
let brightness = self.get_led_brightness(profile_value, region_value)?;
regions.push(LedRegionState {
region: region_name.to_string(),
effect,
mode,
speed,
colors,
brightness,
});
}
Ok(LedState {
profile: profile.to_string(),
regions,
})
}
fn get_led_effect(
&mut self,
profile: u8,
region: u8,
) -> Result<(String, u8, u8, Vec<[u8; 3]>), String> {
let response = self.sr_with(0x0f82, &[profile, region, 0, 0, 0, 0, 0, 0, 0, 0], 0)?;
let effect = led_effect_name(response[2])?.to_string();
let mode = response[3];
let speed = response[4];
let color_count = response[5] as usize;
let mut colors = Vec::new();
for index in 0..color_count.min(2) {
let offset = 6 + index * 3;
colors.push([response[offset], response[offset + 1], response[offset + 2]]);
}
Ok((effect, mode, speed, colors))
}
fn get_led_brightness(&mut self, profile: u8, region: u8) -> Result<u8, String> {
Ok(self.sr_with(0x0f84, &[profile, region, 0], 0)?[2])
}
fn get_button_mapping_state(
&mut self,
profile: &str,
button: &str,
hypershift: bool,
) -> Result<ButtonMappingState, String> {
let profile_value = profile_value(profile)?;
let button_value = button_value(button)?;
let hypershift_value = u8::from(hypershift);
let response = self.sr_with(
0x028c,
&[profile_value, button_value, hypershift_value, 0, 0, 0, 0, 0, 0, 0],
0,
)?;
decode_button_mapping(profile.to_string(), button.to_string(), hypershift, &response[3..10])
}
fn set_button_mapping_state(
&mut self,
mapping: ButtonMappingState,
) -> Result<ButtonMappingState, String> {
let profile_value = profile_value(&mapping.profile)?;
let button_value = button_value(&mapping.button)?;
let hypershift_value = u8::from(mapping.hypershift);
let function_bytes = encode_button_mapping(&mapping)?;
let mut args = Vec::with_capacity(10);
args.extend_from_slice(&[profile_value, button_value, hypershift_value]);
args.extend_from_slice(&function_bytes);
self.sr_with(0x020c, &args, 0)?;
self.get_button_mapping_state(&mapping.profile, &mapping.button, mapping.hypershift)
}
fn get_profile_info(&mut self, profile: &str) -> Result<Vec<u8>, String> {
let profile_value = profile_value(profile)?;
let mut data = Vec::new();
loop {
let mut args = Vec::with_capacity(69);
args.push(profile_value);
args.extend_from_slice(&(data.len() as u16).to_be_bytes());
args.extend_from_slice(&0u16.to_be_bytes());
args.extend_from_slice(&[0; 64]);
let response = self.sr_with(0x0588, &args, 1)?;
let total_size = u16::from_be_bytes([response[1], response[2]]) as usize;
let chunk = &response[3..67];
let remaining = total_size.saturating_sub(data.len());
let take = remaining.min(chunk.len());
data.extend_from_slice(&chunk[..take]);
if data.len() >= total_size {
break;
}
}
Ok(data)
}
fn set_profile_info(&mut self, profile: &str, data: &[u8]) -> Result<(), String> {
let profile_value = profile_value(profile)?;
let total_size = u16::try_from(data.len())
.map_err(|_| "Profile info is too large to write".to_string())?;
let mut offset = 0usize;
while offset < data.len() {
let chunk = &data[offset..(offset + 64).min(data.len())];
let mut args = Vec::with_capacity(5 + chunk.len());
args.push(profile_value);
args.extend_from_slice(&(offset as u16).to_be_bytes());
args.extend_from_slice(&total_size.to_be_bytes());
args.extend_from_slice(chunk);
self.sr_with(0x0508, &args, 1)?;
offset += chunk.len();
}
Ok(())
}
fn export_profile_config(&mut self, profile: &str) -> Result<ProfileConfigBundle, String> {
let basic = self.snapshot(profile)?.basic;
let button_mappings = all_button_names()
.iter()
.flat_map(|button| [false, true].into_iter().map(move |hypershift| (*button, hypershift)))
.map(|(button, hypershift)| {
self.get_button_mapping_state(profile, button, hypershift)
.unwrap_or_else(|error| {
self.append_log(
"warn",
format!(
"profile export fallback for {button} hypershift={} on profile {profile}: {error}",
hypershift
),
);
ButtonMappingState {
profile: profile.to_string(),
button: button.to_string(),
hypershift,
category: "custom".to_string(),
payload: json!({
"fn_class": 0,
"fn_value": [],
"read_error": error
}),
}
})
})
.collect::<Vec<_>>();
let led = self.led_snapshot(profile)?;
let profile_info_hex = self
.get_profile_info(profile)
.ok()
.map(|bytes| bytes_to_hex_compact(&bytes));
Ok(ProfileConfigBundle {
basic,
button_mappings,
led,
profile_info_hex,
})
}
fn import_profile_config(
&mut self,
profile: &str,
bundle: ProfileConfigBundle,
) -> Result<(), String> {
let profile_value = profile_value(profile)?;
let basic = bundle.basic;
self.set_scroll_mode(profile_value, scroll_mode_value(&basic.scroll_mode)?)?;
self.set_bool_command(0x0216, profile_value, basic.scroll_acceleration)?;
self.set_bool_command(0x0217, profile_value, basic.scroll_smart_reel)?;
self.sr_with(0x000e, &[profile_value, basic.polling_rate_ms], 0)?;
let mut dpi_args = Vec::with_capacity(7);
dpi_args.push(profile_value);
dpi_args.extend_from_slice(&basic.dpi_xy[0].to_be_bytes());
dpi_args.extend_from_slice(&basic.dpi_xy[1].to_be_bytes());
dpi_args.extend_from_slice(&[0, 0]);
self.sr_with(0x0405, &dpi_args, 0)?;
let mut stage_args = Vec::with_capacity(38);
stage_args.push(profile_value);
stage_args.push(basic.active_dpi_stage);
stage_args.push(basic.dpi_stages.len() as u8);
for (index, [x, y]) in basic.dpi_stages.iter().copied().enumerate() {
stage_args.push(index as u8);
stage_args.extend_from_slice(&x.to_be_bytes());
stage_args.extend_from_slice(&y.to_be_bytes());
stage_args.extend_from_slice(&[0, 0]);
}
while stage_args.len() < 38 {
stage_args.extend_from_slice(&[0; 7]);
}
self.sr_with(0x0406, &stage_args, 0)?;
for mut mapping in bundle.button_mappings {
mapping.profile = profile.to_string();
self.set_button_mapping_state(mapping)?;
}
for region in bundle.led.regions {
let region_value = led_region_value(&region.region)?;
let effect_value = led_effect_value(&region.effect)?;
let mut effect_args = Vec::with_capacity(6 + region.colors.len() * 3);
effect_args.push(profile_value);
effect_args.push(region_value);
effect_args.push(effect_value);
effect_args.push(region.mode);
effect_args.push(region.speed);
effect_args.push(region.colors.len() as u8);
for color in &region.colors {
effect_args.extend_from_slice(color);
}
self.sr_with(0x0f02, &effect_args, 0)?;
self.sr_with(0x0f04, &[profile_value, region_value, region.brightness], 0)?;
}
if let Some(profile_info_hex) = bundle.profile_info_hex {
let bytes = parse_hex_bytes(&profile_info_hex)?;
self.set_profile_info(profile, &bytes)?;
}
Ok(())
}
}

View file

@ -0,0 +1,187 @@
impl ConnectedDevice {
fn get_device_mode(&mut self) -> Result<(u8, u8), String> {
let response = self.sr_with(0x0084, &[0, 0], 0)?;
Ok((response[0], response[1]))
}
fn set_device_mode(&mut self, mode: &str, param: u8) -> Result<(), String> {
self.sr_with(0x0004, &[device_mode_value(mode)?, param], 0)
.map(|_| ())
}
fn get_sensor_state_enabled(&mut self) -> Result<bool, String> {
let response = self.sr_with(0x0b83, &[0x00, 0x04, 0x00], 0)?;
Ok(response[2] != 0)
}
fn set_sensor_state_enabled(&mut self, enabled: bool) -> Result<(), String> {
self.sr_with(0x0b03, &[0x00, 0x04, u8::from(enabled)], 0)
.map(|_| ())
}
fn set_sensor_calibration(&mut self, enabled: bool) -> Result<(), String> {
self.sr_with(0x0b09, &[0x00, 0x04, 0x00, u8::from(enabled)], 0)
.map(|_| ())
}
fn get_sensor_lift_mode(&mut self) -> Result<String, String> {
let response = self.sr_with(0x0b8b, &[0x00, 0x04, 0x00, 0x00], 0)?;
let value = u16::from_be_bytes([response[2], response[3]]);
Ok(lift_mode_name(value)?.to_string())
}
fn set_sensor_lift_mode(&mut self, lift_mode: &str) -> Result<(), String> {
let value = lift_mode_value(lift_mode)?;
let smart_mode = lift_mode.starts_with("sym_") || lift_mode.starts_with("asym_");
self.set_sensor_state_enabled(!smart_mode)?;
self.sr_with(0x0b0b, &[0x00, 0x04, (value >> 8) as u8, value as u8], 0)
.map(|_| ())
}
fn get_sensor_lift_config(&mut self) -> Result<Vec<u8>, String> {
let response = self.sr_with(0x0b85, &[0x00, 0x04, 0, 0, 0, 0, 0, 0, 0, 0], 0)?;
Ok(response[2..10].to_vec())
}
fn set_sensor_lift_config(&mut self, data: &[u8]) -> Result<(), String> {
if data.len() != 8 {
return Err("Sensor config A must contain exactly 8 bytes".to_string());
}
let mut args = vec![0x00, 0x04];
args.extend_from_slice(data);
self.sr_with(0x0b05, &args, 0).map(|_| ())
}
fn get_sensor_lift_config_b(&mut self) -> Result<Vec<u8>, String> {
let response = self.sr_with(0x0b8c, &[0x00, 0x04, 0, 0, 0, 0, 0], 0)?;
Ok(response[2..7].to_vec())
}
fn set_sensor_lift_config_b(&mut self, data: &[u8]) -> Result<(), String> {
if data.len() != 5 {
return Err("Sensor config B must contain exactly 5 bytes".to_string());
}
let mut args = vec![0x00, 0x04];
args.extend_from_slice(data);
self.sr_with(0x0b0c, &args, 0).map(|_| ())
}
fn get_sensor_lift_config_a(&mut self) -> Result<Vec<u8>, String> {
let response = self.sr_with(0x0b8d, &[0x00, 0x04, 0, 0, 0, 0, 0, 0, 0, 0], 0)?;
Ok(response[2..10].to_vec())
}
fn set_sensor_lift_config_a(&mut self, data: &[u8]) -> Result<(), String> {
if data.len() != 8 {
return Err("Sensor config A must contain exactly 8 bytes".to_string());
}
let mut args = vec![0x00, 0x04];
args.extend_from_slice(data);
self.sr_with(0x0b0d, &args, 0).map(|_| ())
}
fn sensor_snapshot(&mut self) -> Result<SensorState, String> {
let lift_mode = self.get_sensor_lift_mode()?;
let sensor_enabled = self.get_sensor_state_enabled()?;
let device_mode = device_mode_name(self.get_device_mode()?.0)?.to_string();
let retrieved_calib = self.get_sensor_lift_config()?;
let (param_a, param_b) = if lift_mode.starts_with("config2") || lift_mode.starts_with("calib2") {
(self.get_sensor_lift_config_a()?, self.get_sensor_lift_config_b()?)
} else {
(self.get_sensor_lift_config()?, Vec::new())
};
Ok(SensorState {
lift_mode,
sensor_enabled,
device_mode,
retrieved_calib,
param_a,
param_b,
})
}
fn start_sensor_calibration(&mut self) -> Result<(), String> {
self.set_device_mode("driver", 0)?;
self.set_sensor_state_enabled(true)?;
self.set_sensor_lift_mode("calib1")?;
self.set_sensor_calibration(false)
}
fn stop_sensor_calibration(&mut self) -> Result<(), String> {
self.set_sensor_calibration(true)?;
self.set_sensor_state_enabled(false)?;
self.set_device_mode("normal", 0)
}
fn set_sensor_params(&mut self, param_a: &[u8], param_b: &[u8]) -> Result<(), String> {
if param_b.is_empty() {
self.set_sensor_lift_config(param_a)
} else {
self.set_sensor_lift_config_a(param_a)?;
self.set_sensor_lift_config_b(param_b)
}
}
fn get_flash_usage(&mut self) -> Result<(u16, u32, u32, u32), String> {
let response = self.sr_with(0x068e, &[0; 14], 0)?;
Ok((
u16::from_be_bytes([response[0], response[1]]),
u32::from_be_bytes([response[2], response[3], response[4], response[5]]),
u32::from_be_bytes([response[6], response[7], response[8], response[9]]),
u32::from_be_bytes([response[10], response[11], response[12], response[13]]),
))
}
fn info_snapshot(&mut self) -> Result<InfoState, String> {
let (_, flash_total, flash_free, flash_recycled) = self.get_flash_usage()?;
Ok(InfoState {
flash_total,
flash_free,
flash_recycled,
macro_count: self.get_macro_count()?,
})
}
fn append_log(&self, level: &str, message: String) {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
if let Ok(mut logs) = self.logs.lock() {
logs.push(DebugLogEntry {
timestamp_ms,
level: level.to_string(),
message,
});
if logs.len() > 500 {
let drop_count = logs.len() - 500;
logs.drain(0..drop_count);
}
}
}
fn send_feature_report(&self, report: &[u8; 90]) -> Result<(), String> {
let mut buffer = [0u8; 91];
buffer[1..].copy_from_slice(report);
hidraw_ioctl(
self.file.as_raw_fd(),
hid_iocsfeature(buffer.len()),
&mut buffer,
)
.map(|_| ())
.map_err(|err| format!("Failed to send feature report: {err}"))
}
fn get_feature_report(&self) -> Result<[u8; 90], String> {
let mut buffer = [0u8; 91];
hidraw_ioctl(
self.file.as_raw_fd(),
hid_iocgfeature(buffer.len()),
&mut buffer,
)
.map_err(|err| format!("Failed to read feature report: {err}"))?;
let mut report = [0u8; 90];
report.copy_from_slice(&buffer[1..]);
Ok(report)
}
}

View file

@ -0,0 +1,166 @@
fn try_connect_summary(
summary: DeviceSummary,
logs: std::sync::Arc<Mutex<Vec<DebugLogEntry>>>,
) -> Result<(ConnectedDevice, DeviceState), String> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(&summary.path)
.map_err(|err| format!("Could not open. Check hidraw permissions or udev rules: {err}"))?;
let mut device = ConnectedDevice { file, summary, logs };
let snapshot = device.snapshot("direct")?;
Ok((device, snapshot))
}
fn connection_candidates(
discovered: &[DeviceSummary],
selected: &DeviceSummary,
) -> Vec<DeviceSummary> {
let mut candidates: Vec<DeviceSummary> = discovered
.iter()
.filter(|device| {
device.vendor_id == selected.vendor_id
&& device.product_id == selected.product_id
&& device.probe_group == selected.probe_group
})
.cloned()
.collect();
candidates.sort_by_key(|device| {
(
device.path != selected.path,
interface_probe_priority(device.interface_number),
device.path.clone(),
)
});
candidates
}
fn interface_probe_priority(interface_number: Option<u8>) -> u8 {
match interface_number {
Some(3) => 0,
Some(2) => 1,
Some(1) => 2,
Some(0) => 3,
Some(other) => 4 + other,
None => u8::MAX,
}
}
fn discover_devices() -> io::Result<Vec<DeviceSummary>> {
let mut devices = Vec::new();
let hidraw_root = Path::new("/sys/class/hidraw");
if !hidraw_root.exists() {
return Ok(devices);
}
for entry in fs::read_dir(hidraw_root)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let Some((vendor_id, product_id, product_name, serial)) =
read_hidraw_identity(&entry.path())?
else {
continue;
};
let Some(supported) = SUPPORTED_DEVICES
.iter()
.find(|device| device.product_id == product_id)
else {
continue;
};
let interface_number = read_interface_number(&entry.path())?;
let probe_group = read_probe_group(&entry.path())?;
let manufacturer = read_ancestor_file(&entry.path(), "manufacturer")?;
let product_usb_string = read_ancestor_file(&entry.path(), "product")?;
devices.push(DeviceSummary {
path: format!("/dev/{name}"),
vendor_id,
product_id,
product_name: product_usb_string.unwrap_or(product_name),
manufacturer,
serial,
interface_number,
supported_name: supported.name.to_string(),
probe_group,
});
}
devices.sort_by_key(|device| {
(
device.probe_group.clone(),
interface_probe_priority(device.interface_number),
device.path.clone(),
)
});
Ok(devices)
}
fn read_hidraw_identity(
hidraw_path: &Path,
) -> io::Result<Option<(u16, u16, String, Option<String>)>> {
let uevent = fs::read_to_string(hidraw_path.join("device/uevent"))?;
let mut vendor_id = None;
let mut product_id = None;
let mut product_name = None;
let mut serial = None;
for line in uevent.lines() {
if let Some(value) = line.strip_prefix("HID_ID=") {
let parts: Vec<&str> = value.split(':').collect();
if parts.len() == 3 {
vendor_id = u16::from_str_radix(parts[1].trim_start_matches("0000"), 16).ok();
product_id = u16::from_str_radix(parts[2].trim_start_matches("0000"), 16).ok();
}
} else if let Some(value) = line.strip_prefix("HID_NAME=") {
product_name = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("HID_UNIQ=") {
if !value.is_empty() {
serial = Some(value.to_string());
}
}
}
match (vendor_id, product_id) {
(Some(RAZER_VENDOR_ID), Some(product_id)) => Ok(Some((
RAZER_VENDOR_ID,
product_id,
product_name.unwrap_or_else(|| "Razer HID device".to_string()),
serial,
))),
_ => Ok(None),
}
}
fn read_interface_number(hidraw_path: &Path) -> io::Result<Option<u8>> {
let Some(value) = read_ancestor_file(hidraw_path, "bInterfaceNumber")? else {
return Ok(None);
};
Ok(u8::from_str_radix(value.trim(), 16).ok())
}
fn read_probe_group(hidraw_path: &Path) -> io::Result<String> {
let canonical = fs::canonicalize(hidraw_path)?;
let interface_path = canonical
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "hidraw device has no parent"))?;
let usb_device = interface_path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "HID interface has no USB parent")
})?;
Ok(usb_device.to_string_lossy().to_string())
}
fn read_ancestor_file(path: &Path, file_name: &str) -> io::Result<Option<String>> {
let mut current = fs::canonicalize(path)?;
for _ in 0..8 {
let candidate = current.join(file_name);
if candidate.exists() {
return Ok(Some(fs::read_to_string(candidate)?.trim().to_string()));
}
if !current.pop() {
break;
}
}
Ok(None)
}

View file

@ -0,0 +1,33 @@
fn hidraw_ioctl(fd: libc::c_int, request: libc::c_ulong, buffer: &mut [u8]) -> io::Result<i32> {
let result = unsafe { libc::ioctl(fd, request, buffer.as_mut_ptr()) };
if result < 0 {
Err(io::Error::last_os_error())
} else {
Ok(result)
}
}
fn hid_iocsfeature(len: usize) -> libc::c_ulong {
ioc(IOC_READ | IOC_WRITE, b'H', 0x06, len)
}
fn hid_iocgfeature(len: usize) -> libc::c_ulong {
ioc(IOC_READ | IOC_WRITE, b'H', 0x07, len)
}
const IOC_NRBITS: libc::c_ulong = 8;
const IOC_TYPEBITS: libc::c_ulong = 8;
const IOC_SIZEBITS: libc::c_ulong = 14;
const IOC_NRSHIFT: libc::c_ulong = 0;
const IOC_TYPESHIFT: libc::c_ulong = IOC_NRSHIFT + IOC_NRBITS;
const IOC_SIZESHIFT: libc::c_ulong = IOC_TYPESHIFT + IOC_TYPEBITS;
const IOC_DIRSHIFT: libc::c_ulong = IOC_SIZESHIFT + IOC_SIZEBITS;
const IOC_WRITE: libc::c_ulong = 1;
const IOC_READ: libc::c_ulong = 2;
fn ioc(dir: libc::c_ulong, ty: u8, nr: u8, size: usize) -> libc::c_ulong {
(dir << IOC_DIRSHIFT)
| ((ty as libc::c_ulong) << IOC_TYPESHIFT)
| ((nr as libc::c_ulong) << IOC_NRSHIFT)
| ((size as libc::c_ulong) << IOC_SIZESHIFT)
}

View file

@ -0,0 +1,183 @@
const MACRO_OP_KEYBOARD_DOWN: u8 = 0x01;
const MACRO_OP_KEYBOARD_UP: u8 = 0x02;
const MACRO_OP_SYSTEM_A: u8 = 0x03;
const MACRO_OP_SYSTEM_B: u8 = 0x04;
const MACRO_OP_CONSUMER_A: u8 = 0x05;
const MACRO_OP_CONSUMER_B: u8 = 0x06;
const MACRO_OP_MOUSE_BUTTON: u8 = 0x08;
const MACRO_OP_MOUSE_WHEEL: u8 = 0x0a;
const MACRO_OP_DELAY_1: u8 = 0x11;
const MACRO_OP_DELAY_2: u8 = 0x12;
fn decode_macro_operations(data: &[u8]) -> Result<Vec<MacroOperationState>, String> {
let mut operations = Vec::new();
let mut offset = 0usize;
while offset < data.len() {
let op_type = data[offset];
offset += 1;
let value_len = match op_type {
MACRO_OP_KEYBOARD_DOWN
| MACRO_OP_KEYBOARD_UP
| MACRO_OP_SYSTEM_A
| MACRO_OP_SYSTEM_B
| MACRO_OP_MOUSE_BUTTON
| MACRO_OP_MOUSE_WHEEL
| MACRO_OP_DELAY_1 => 1,
MACRO_OP_CONSUMER_A | MACRO_OP_CONSUMER_B | MACRO_OP_DELAY_2 => 2,
other => return Err(format!("Unsupported macro op type {other:#04x}")),
};
if offset + value_len > data.len() {
return Err("Macro operation payload is truncated".to_string());
}
let value = &data[offset..offset + value_len];
offset += value_len;
let operation = match op_type {
MACRO_OP_KEYBOARD_DOWN => MacroOperationState {
category: "keyboard".to_string(),
payload: json!({"key": value[0], "is_up": false}),
},
MACRO_OP_KEYBOARD_UP => MacroOperationState {
category: "keyboard".to_string(),
payload: json!({"key": value[0], "is_up": true}),
},
MACRO_OP_SYSTEM_A => MacroOperationState {
category: "system".to_string(),
payload: json!({"key": value[0], "is_b": false}),
},
MACRO_OP_SYSTEM_B => MacroOperationState {
category: "system".to_string(),
payload: json!({"key": value[0], "is_b": true}),
},
MACRO_OP_CONSUMER_A => MacroOperationState {
category: "consumer".to_string(),
payload: json!({"key": u16::from_be_bytes([value[0], value[1]]), "is_b": false}),
},
MACRO_OP_CONSUMER_B => MacroOperationState {
category: "consumer".to_string(),
payload: json!({"key": u16::from_be_bytes([value[0], value[1]]), "is_b": true}),
},
MACRO_OP_MOUSE_BUTTON => MacroOperationState {
category: "mouse_button".to_string(),
payload: json!({"button": decode_macro_mouse_buttons(value[0])}),
},
MACRO_OP_MOUSE_WHEEL => MacroOperationState {
category: "mouse_wheel".to_string(),
payload: json!({"value": i8::from_be_bytes([value[0]])}),
},
MACRO_OP_DELAY_1 => MacroOperationState {
category: "delay".to_string(),
payload: json!({"value": value[0]}),
},
MACRO_OP_DELAY_2 => MacroOperationState {
category: "delay".to_string(),
payload: json!({"value": u16::from_be_bytes([value[0], value[1]])}),
},
_ => unreachable!(),
};
operations.push(operation);
}
Ok(operations)
}
fn encode_macro_operations(operations: &[MacroOperationState]) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
for operation in operations {
let payload = operation
.payload
.as_object()
.ok_or_else(|| "Macro operation payload must be an object".to_string())?;
match operation.category.as_str() {
"keyboard" => {
out.push(if payload.get("is_up").and_then(Value::as_bool).unwrap_or(false) {
MACRO_OP_KEYBOARD_UP
} else {
MACRO_OP_KEYBOARD_DOWN
});
out.push(get_u8(payload, "key")?);
}
"system" => {
out.push(if payload.get("is_b").and_then(Value::as_bool).unwrap_or(false) {
MACRO_OP_SYSTEM_B
} else {
MACRO_OP_SYSTEM_A
});
out.push(get_u8(payload, "key")?);
}
"consumer" => {
let key = get_u16(payload, "key")?;
out.push(if payload.get("is_b").and_then(Value::as_bool).unwrap_or(false) {
MACRO_OP_CONSUMER_B
} else {
MACRO_OP_CONSUMER_A
});
out.extend_from_slice(&key.to_be_bytes());
}
"mouse_button" => {
out.push(MACRO_OP_MOUSE_BUTTON);
out.push(encode_macro_mouse_buttons(get_string(payload, "button")?)?);
}
"mouse_wheel" => {
out.push(MACRO_OP_MOUSE_WHEEL);
let value = payload
.get("value")
.and_then(Value::as_i64)
.filter(|value| (-128..=127).contains(value))
.ok_or_else(|| "mouse_wheel.value must be between -128 and 127".to_string())?;
out.push((value as i8) as u8);
}
"delay" => {
let value = payload
.get("value")
.and_then(Value::as_u64)
.filter(|value| *value <= u16::MAX as u64)
.ok_or_else(|| "delay.value must be between 0 and 65535".to_string())?
as u16;
if value < 0x100 {
out.push(MACRO_OP_DELAY_1);
out.push(value as u8);
} else {
out.push(MACRO_OP_DELAY_2);
out.extend_from_slice(&value.to_be_bytes());
}
}
other => return Err(format!("Unsupported macro operation category '{other}'")),
}
}
Ok(out)
}
fn decode_macro_mouse_buttons(value: u8) -> String {
if value == 0 {
return "NONE".to_string();
}
let mut flags = Vec::new();
for (flag, name) in [
(0x01, "LEFT"),
(0x02, "RIGHT"),
(0x04, "MIDDLE"),
(0x08, "BACKWARD"),
(0x10, "FORWARD"),
] {
if value & flag != 0 {
flags.push(name);
}
}
flags.join("|")
}
fn encode_macro_mouse_buttons(value: &str) -> Result<u8, String> {
let mut flags = 0u8;
for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) {
flags |= match part {
"NONE" => 0x00,
"LEFT" => 0x01,
"RIGHT" => 0x02,
"MIDDLE" => 0x04,
"BACKWARD" => 0x08,
"FORWARD" => 0x10,
other => return Err(format!("Unknown macro mouse button flag '{other}'")),
};
}
Ok(flags)
}

View file

@ -0,0 +1,142 @@
#[derive(Clone, Copy)]
struct SupportedDevice {
product_id: u16,
name: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DeviceSummary {
path: String,
vendor_id: u16,
product_id: u16,
product_name: String,
manufacturer: Option<String>,
serial: Option<String>,
interface_number: Option<u8>,
supported_name: String,
probe_group: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BasicSettings {
profile: String,
scroll_mode: String,
scroll_acceleration: bool,
scroll_smart_reel: bool,
polling_rate_ms: u8,
dpi_xy: [u16; 2],
dpi_stages: Vec<[u16; 2]>,
active_dpi_stage: u8,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DeviceState {
device: DeviceSummary,
serial: String,
firmware: String,
profiles: Vec<String>,
basic: BasicSettings,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LedRegionState {
region: String,
effect: String,
mode: u8,
speed: u8,
colors: Vec<[u8; 3]>,
brightness: u8,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LedState {
profile: String,
regions: Vec<LedRegionState>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ButtonMappingState {
profile: String,
button: String,
hypershift: bool,
category: String,
payload: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProfileConfigBundle {
basic: BasicSettings,
button_mappings: Vec<ButtonMappingState>,
led: LedState,
profile_info_hex: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MacroOperationState {
category: String,
payload: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MacroDefinition {
macro_id: u16,
macro_info_hex: Option<String>,
operations: Vec<MacroOperationState>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SensorState {
lift_mode: String,
sensor_enabled: bool,
device_mode: String,
retrieved_calib: Vec<u8>,
param_a: Vec<u8>,
param_b: Vec<u8>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InfoState {
flash_total: u32,
flash_free: u32,
flash_recycled: u32,
macro_count: u16,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DebugLogEntry {
timestamp_ms: u64,
level: String,
message: String,
}
struct AppState {
connected: Mutex<Option<ConnectedDevice>>,
logs: std::sync::Arc<Mutex<Vec<DebugLogEntry>>>,
}
impl Default for AppState {
fn default() -> Self {
Self {
connected: Mutex::new(None),
logs: std::sync::Arc::new(Mutex::new(Vec::new())),
}
}
}
struct ConnectedDevice {
summary: DeviceSummary,
file: File,
logs: std::sync::Arc<Mutex<Vec<DebugLogEntry>>>,
}

View file

@ -0,0 +1,187 @@
fn profile_value(profile: &str) -> Result<u8, String> {
match profile {
"direct" => Ok(0x00),
"white" | "default" => Ok(0x01),
"red" => Ok(0x02),
"green" => Ok(0x03),
"blue" => Ok(0x04),
"cyan" => Ok(0x05),
_ => Err(format!("Unsupported profile '{profile}'")),
}
}
fn profile_name(profile: u8) -> Result<&'static str, String> {
match profile {
0x00 => Ok("direct"),
0x01 => Ok("white"),
0x02 => Ok("red"),
0x03 => Ok("green"),
0x04 => Ok("blue"),
0x05 => Ok("cyan"),
_ => Err(format!("Unknown profile value {profile}")),
}
}
fn scroll_mode_value(mode: &str) -> Result<u8, String> {
match mode {
"tactile" => Ok(0),
"freespin" => Ok(1),
_ => Err(format!("Unsupported scroll mode '{mode}'")),
}
}
fn led_region_value(region: &str) -> Result<u8, String> {
match region {
"all" => Ok(0x00),
"wheel" => Ok(0x01),
"logo" => Ok(0x04),
"strip" => Ok(0x0a),
_ => Err(format!("Unsupported LED region '{region}'")),
}
}
fn led_effect_value(effect: &str) -> Result<u8, String> {
match effect {
"off" | "disabled" => Ok(0x00),
"static" => Ok(0x01),
"spectrum" => Ok(0x03),
"wave" => Ok(0x04),
"custom" => Ok(0x08),
_ => Err(format!("Unsupported LED effect '{effect}'")),
}
}
fn led_effect_name(effect: u8) -> Result<&'static str, String> {
match effect {
0x00 => Ok("off"),
0x01 => Ok("static"),
0x03 => Ok("spectrum"),
0x04 => Ok("wave"),
0x08 => Ok("custom"),
_ => Err(format!("Unknown LED effect value {effect}")),
}
}
fn button_value(button: &str) -> Result<u8, String> {
match button {
"left" => Ok(0x01),
"right" => Ok(0x02),
"middle" => Ok(0x03),
"backward" => Ok(0x04),
"forward" => Ok(0x05),
"wheel_up" => Ok(0x09),
"wheel_down" => Ok(0x0a),
"bottom" => Ok(0x0e),
"aim" => Ok(0x0f),
"wheel_left" => Ok(0x34),
"wheel_right" => Ok(0x35),
"middle_backward" => Ok(0x60),
"middle_forward" => Ok(0x6a),
_ => Err(format!("Unsupported button '{button}'")),
}
}
fn all_button_names() -> &'static [&'static str] {
&[
"aim",
"left",
"middle",
"right",
"forward",
"wheel_up",
"middle_forward",
"wheel_left",
"backward",
"wheel_down",
"middle_backward",
"wheel_right",
"bottom",
]
}
fn bytes_to_hex_compact(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn parse_hex_bytes(input: &str) -> Result<Vec<u8>, String> {
let compact = input.chars().filter(|ch| !ch.is_whitespace()).collect::<String>();
if compact.len() % 2 != 0 {
return Err("Hex input must contain an even number of characters".to_string());
}
(0..compact.len())
.step_by(2)
.map(|index| {
u8::from_str_radix(&compact[index..index + 2], 16)
.map_err(|_| format!("Invalid hex byte '{}'", &compact[index..index + 2]))
})
.collect()
}
fn device_mode_name(mode: u8) -> Result<&'static str, String> {
match mode {
0x00 => Ok("normal"),
0x01 => Ok("bootloader"),
0x02 => Ok("test"),
0x03 => Ok("driver"),
_ => Err(format!("Unknown device mode {mode}")),
}
}
fn device_mode_value(mode: &str) -> Result<u8, String> {
match mode {
"normal" => Ok(0x00),
"bootloader" => Ok(0x01),
"test" => Ok(0x02),
"driver" => Ok(0x03),
_ => Err(format!("Unknown device mode '{mode}'")),
}
}
fn lift_mode_name(value: u16) -> Result<&'static str, String> {
match value {
0x0000 => Ok("none"),
0x0100 => Ok("sym_1"),
0x0101 => Ok("sym_2"),
0x0102 => Ok("sym_3"),
0x0200 => Ok("asym_12"),
0x0201 => Ok("asym_13"),
0x0202 => Ok("asym_23"),
0x0300 => Ok("config1"),
0x0400 => Ok("config2"),
0x0500 => Ok("calib1"),
0x0600 => Ok("calib2"),
_ => Err(format!("Unknown lift mode 0x{value:04x}")),
}
}
fn lift_mode_value(value: &str) -> Result<u16, String> {
match value {
"none" => Ok(0x0000),
"sym_1" => Ok(0x0100),
"sym_2" => Ok(0x0101),
"sym_3" => Ok(0x0102),
"asym_12" => Ok(0x0200),
"asym_13" => Ok(0x0201),
"asym_23" => Ok(0x0202),
"config1" => Ok(0x0300),
"config2" => Ok(0x0400),
"calib1" => Ok(0x0500),
"calib2" => Ok(0x0600),
_ => Err(format!("Unknown lift mode '{value}'")),
}
}
fn fn_mouse_value(button: &str) -> Result<u8, String> {
match button {
"left" => Ok(0x01),
"right" => Ok(0x02),
"middle" => Ok(0x03),
"backward" => Ok(0x04),
"forward" => Ok(0x05),
"wheel_up" => Ok(0x09),
"wheel_down" => Ok(0x0a),
"wheel_left" => Ok(0x68),
"wheel_right" => Ok(0x69),
_ => Err(format!("Unsupported mouse function '{button}'")),
}
}

81
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
fs::{self, File, OpenOptions},
io,
os::fd::AsRawFd,
path::Path,
sync::Mutex,
thread::sleep,
time::Duration,
};
const RAZER_VENDOR_ID: u16 = 0x1532;
const SUPPORTED_DEVICES: &[SupportedDevice] = &[
SupportedDevice {
product_id: 0x0099,
name: "Razer Basilisk V3",
},
SupportedDevice {
product_id: 0x00aa,
name: "Razer Basilisk V3 Pro (wired)",
},
SupportedDevice {
product_id: 0x00ab,
name: "Razer Basilisk V3 Pro (wireless)",
},
];
include!("backend/models.rs");
include!("backend/commands.rs");
include!("backend/device.rs");
include!("backend/discovery.rs");
include!("backend/value_maps.rs");
include!("backend/button_mapping.rs");
include!("backend/macro_ops.rs");
include!("backend/ioctl.rs");
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState::default())
.invoke_handler(tauri::generate_handler![
list_supported_devices,
connect_device,
refresh_device_state,
set_scroll_mode,
set_scroll_acceleration,
set_scroll_smart_reel,
set_polling_rate,
set_dpi_xy,
set_dpi_stages,
create_profile,
delete_profile,
get_led_state,
set_led_effect,
set_led_brightness,
apply_led_to_all_regions,
get_button_mapping,
set_button_mapping,
export_profile_config,
import_profile_config,
list_macros,
get_macro_definition,
set_macro_definition,
delete_macro,
reset_macro_flash,
get_debug_logs,
clear_debug_logs,
get_sensor_state,
set_sensor_lift_mode,
start_sensor_calibration,
stop_sensor_calibration,
set_sensor_params,
get_info_state,
send_raw_command,
reset_flash,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
razer_linux_desktop_lib::run()
}

36
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,36 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "razer-linux-desktop",
"version": "0.1.0",
"identifier": "one.daichendt.razer-linux-desktop",
"build": {
"beforeDevCommand": "NO_COLOR=false trunk serve",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "NO_COLOR=false trunk build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "razer-linux-desktop",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["appimage"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}