commit 2a1252cc7aca234ae2802d1db26c4e4fe9c88ec0 Author: Alexander Daichendt Date: Sun May 17 21:33:41 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48c3ca4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/target/ +/Cargo.lock diff --git a/.taurignore b/.taurignore new file mode 100644 index 0000000..1ebdc6d --- /dev/null +++ b/.taurignore @@ -0,0 +1,3 @@ +/src +/public +/Cargo.toml \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e6d9d21 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "emmet.includeLanguages": { + "rust": "html" + } +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d5e7079 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "razer-linux-desktop-ui" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[dependencies] +leptos = { version = "0.8", features = ["csr"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +serde-wasm-bindgen = "0.6" +console_error_panic_hook = "0.1.7" + +[workspace] +members = ["src-tauri"] diff --git a/MIGRATION_STATUS.md b/MIGRATION_STATUS.md new file mode 100644 index 0000000..29e4b48 --- /dev/null +++ b/MIGRATION_STATUS.md @@ -0,0 +1,94 @@ +# Migration Status + +## Implemented + +- Replaced the default Tauri + Leptos starter UI with a desktop-oriented Razer app shell. +- Added native Linux hidraw discovery for supported devices: + - Razer Basilisk V3 `1532:0099` + - Razer Basilisk V3 Pro wired `1532:00aa` + - Razer Basilisk V3 Pro wireless `1532:00ab` +- Ported the Python report framing used by `qdrazer.protocol.Report` into Rust: + - 90-byte report payload plus report id + - Basilisk transaction id `0x1f` + - command class/id layout + - XOR CRC over bytes 2 through 87 + - feature report send/receive through `HIDIOCSFEATURE` and `HIDIOCGFEATURE` +- Implemented a native connect and snapshot flow: + - serial number + - firmware version + - available onboard profiles + - basic profile settings +- Implemented native writes for the first vertical slice: + - scroll wheel mode + - scroll acceleration + - smart reel + - polling delay + - current DPI X/Y + - DPI stages get/set, stage count, and active stage selection +- Basic tab parity improvements from the Vue app: + - editable DPI stage list + - stage count control + - active stage control + - `Y = X` helper for DPI stages +- Profile tab parity improvements from the Vue app: + - create profile + - delete profile + - direct + white/red/green/blue/cyan slot management UI + - YAML export/import for the selected profile's basic, button, and LED bundle + - backend profile info chunked read/write support (`0x0588` / `0x0508`) carried in exported/imported bundles +- Connect flow parity improvements from the Vue app: + - scan + select device + - auto-connect first supported device after scan + - connect status and error handling +- LED tab parity improvements from the Vue app: + - region selection for wheel/logo/strip + - off/static/spectrum/wave effects + - per-region speed and brightness controls + - apply-to-all regions helper +- Button tab parity improvements from the Vue app: + - per-button assignment editing for normal and hypershift layers + - categories for mouse, keyboard, macro, DPI switch, profile switch, system, consumer, hypershift toggle, scroll mode toggle, and custom payloads + - native get/set button mapping commands through the Rust backend +- Macro tab parity improvements from the Vue app: + - macro list loading + - load/edit/save/delete by macro ID + - YAML export/import for all macro operation lists + - flash reset action + - native macro op encode/decode for keyboard, system, consumer, mouse button, mouse wheel, and delay operations + - preserve loaded `macro_info_hex` when round-tripping an existing macro through the editor +- Sensor tab parity improvements from the Vue app: + - lift mode selection for smart, smart asym, config, and self calibration modes + - calibration start/stop controls + - retrieved calibration data editor + - parameter calculator for symmetric and asymmetric lift settings + - set-params write path for config A / config B blobs +- Debug tooling improvements: + - raw sent/received report logging from the Tauri hidraw backend + - log viewer and clear action in the Info tab + - warning/error entries for exclusive-access and command failures + +## Follow-Up Fixes Applied During Parity Review + +- Removed the user-facing `Use Without Mouse` entry point. +- Added auto-connect after device scan. +- Replaced the polling delay number input with the source-style fixed Hz choices. +- Made short or partially decoded button mappings fall back to `custom/raw` instead of failing the tab. +- Fixed the Sensor tab reactive loop that could freeze the app. +- Restored the stylesheet after the broken import split and re-split it into trunk-linked CSS files under the file-size cap. +- Fixed the button-mapping save payload shape for the Tauri `set_button_mapping` command. +- Fixed LED option/layout regressions caused by over-aggressive wrapping rules. +- Serialized "Apply to all regions" through a single backend command instead of racing multiple hidraw writes from the frontend. + +## Source Features Still To Port + +- Completion audit and parity fixes against the Vue reference still remain. + +## Notes + +This target no longer embeds Pyodide or uses WebHID. The backend is Tauri-native Rust and currently targets Linux hidraw devices. Running the real device workflow requires read/write permission for the matching `/dev/hidraw*` node, typically handled with a udev rule. + +The active bundle target is AppImage. On modern rolling Linux distributions, linuxdeploy's bundled `strip` binary can fail on `.relr.dyn` ELF sections, so build AppImage artifacts with: + +```sh +NO_STRIP=true cargo tauri build +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..77812aa --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Tauri + Leptos + +This template should help get you started developing with Tauri and Leptos. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). diff --git a/Trunk.toml b/Trunk.toml new file mode 100644 index 0000000..c9a88b7 --- /dev/null +++ b/Trunk.toml @@ -0,0 +1,9 @@ +[build] +target = "./index.html" + +[watch] +ignore = ["./src-tauri"] + +[serve] +port = 1420 +open = false diff --git a/index.html b/index.html new file mode 100644 index 0000000..19d120d --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + Razer Basilisk V3 Onboard Memory Tools + + + + + + + + diff --git a/public/leptos.svg b/public/leptos.svg new file mode 100644 index 0000000..7fc2154 --- /dev/null +++ b/public/leptos.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/snakemouse.svg b/public/snakemouse.svg new file mode 100644 index 0000000..49b02cd --- /dev/null +++ b/public/snakemouse.svg @@ -0,0 +1,97 @@ + + diff --git a/public/tauri.svg b/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/src-tauri/.gitignore @@ -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 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..b9384d8 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..4cdbf49 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -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" + ] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..0ca4f27 Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..b81f820 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..624c7bf Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..c021d2b Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..6219700 Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..f9bc048 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..d5fbfb2 Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..63440d7 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..f3f705a Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..4556388 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..12a5bce Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/backend/button_mapping.rs b/src-tauri/src/backend/button_mapping.rs new file mode 100644 index 0000000..2754a47 --- /dev/null +++ b/src-tauri/src/backend/button_mapping.rs @@ -0,0 +1,397 @@ +fn decode_button_mapping( + profile: String, + button: String, + hypershift: bool, + data: &[u8], +) -> Result { + 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::, _>>()?; + (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 { + 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 { + 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 { + 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) -> Result<(u8, Vec), 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 { + 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 { + 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) -> Result<(u8, Vec), 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 { + 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 { + 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, 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, key: &str) -> Result { + 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, key: &str) -> Result { + 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}'")) +} diff --git a/src-tauri/src/backend/commands.rs b/src-tauri/src/backend/commands.rs new file mode 100644 index 0000000..f230726 --- /dev/null +++ b/src-tauri/src/backend/commands.rs @@ -0,0 +1,440 @@ +#[tauri::command] +fn list_supported_devices() -> Result, String> { + discover_devices().map_err(|err| err.to_string()) +} + +#[tauri::command] +fn connect_device(path: String, state: tauri::State<'_, AppState>) -> Result { + 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 { + with_device(&state, |device| device.snapshot(&profile)) +} + +#[tauri::command] +fn set_scroll_mode( + profile: String, + mode: String, + state: tauri::State<'_, AppState>, +) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let profile = profile_value(&profile)?; + with_device(&state, |device| { + device.sr_with(0x0502, &[profile], 0)?; + device.snapshot(¤t_profile) + }) +} + +#[tauri::command] +fn delete_profile( + profile: String, + next_profile: String, + state: tauri::State<'_, AppState>, +) -> Result { + 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 { + 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 { + with_device(&state, |device| { + let profile = profile_value(&profile)?; + let region = led_region_value(®ion)?; + 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 { + with_device(&state, |device| { + let profile = profile_value(&profile)?; + let region = led_region_value(®ion)?; + 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 { + with_device(&state, |device| { + let profile_value = profile_value(&profile)?; + let effect_value = led_effect_value(®ion_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 ®ion_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 { + 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 { + with_device(&state, |device| device.set_button_mapping_state(mapping)) +} + +#[tauri::command] +fn export_profile_config( + profile: String, + state: tauri::State<'_, AppState>, +) -> Result { + with_device(&state, |device| device.export_profile_config(&profile)) +} + +#[tauri::command] +fn import_profile_config( + profile: String, + bundle: ProfileConfigBundle, + state: tauri::State<'_, AppState>, +) -> Result { + with_device(&state, |device| { + device.import_profile_config(&profile, bundle)?; + device.snapshot(&profile) + }) +} + +#[tauri::command] +fn list_macros(state: tauri::State<'_, AppState>) -> Result, String> { + with_device(&state, |device| device.get_macro_list()) +} + +#[tauri::command] +fn get_macro_definition( + macro_id: u16, + state: tauri::State<'_, AppState>, +) -> Result { + 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, 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, 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, String> { + with_device(&state, |device| { + device.reset_macro_flash()?; + device.get_macro_list() + }) +} + +#[tauri::command] +fn get_debug_logs(state: tauri::State<'_, AppState>) -> Result, 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 { + with_device(&state, |device| device.sensor_snapshot()) +} + +#[tauri::command] +fn set_sensor_lift_mode( + lift_mode: String, + state: tauri::State<'_, AppState>, +) -> Result { + 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 { + with_device(&state, |device| { + device.start_sensor_calibration()?; + device.sensor_snapshot() + }) +} + +#[tauri::command] +fn stop_sensor_calibration(state: tauri::State<'_, AppState>) -> Result { + with_device(&state, |device| { + device.stop_sensor_calibration()?; + device.sensor_snapshot() + }) +} + +#[tauri::command] +fn set_sensor_params( + param_a: Vec, + param_b: Vec, + state: tauri::State<'_, AppState>, +) -> Result { + with_device(&state, |device| { + device.set_sensor_params(¶m_a, ¶m_b)?; + device.sensor_snapshot() + }) +} + +#[tauri::command] +fn get_info_state(state: tauri::State<'_, AppState>) -> Result { + with_device(&state, |device| device.info_snapshot()) +} + +#[tauri::command] +fn send_raw_command( + command: u16, + args: Vec, + state: tauri::State<'_, AppState>, +) -> Result, String> { + with_device(&state, |device| Ok(device.sr_with(command, &args, 0)?.to_vec())) +} + +#[tauri::command] +fn reset_flash(state: tauri::State<'_, AppState>) -> Result { + with_device(&state, |device| { + device.reset_macro_flash()?; + device.info_snapshot() + }) +} + +fn with_device( + state: &tauri::State<'_, AppState>, + f: impl FnOnce(&mut ConnectedDevice) -> Result, +) -> Result { + 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) +} diff --git a/src-tauri/src/backend/device.rs b/src-tauri/src/backend/device.rs new file mode 100644 index 0000000..30b9573 --- /dev/null +++ b/src-tauri/src/backend/device.rs @@ -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"); diff --git a/src-tauri/src/backend/device_parts/core.rs b/src-tauri/src/backend/device_parts/core.rs new file mode 100644 index 0000000..8e8d762 --- /dev/null +++ b/src-tauri/src/backend/device_parts/core.rs @@ -0,0 +1,196 @@ +impl ConnectedDevice { + fn snapshot(&mut self, profile: &str) -> Result { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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()) + } + + +} diff --git a/src-tauri/src/backend/device_parts/macro.rs b/src-tauri/src/backend/device_parts/macro.rs new file mode 100644 index 0000000..efe56f7 --- /dev/null +++ b/src-tauri/src/backend/device_parts/macro.rs @@ -0,0 +1,157 @@ +impl ConnectedDevice { + fn get_macro_count(&mut self) -> Result { + Ok(u16::from_be_bytes(self.sr_with(0x0680, &[0, 0], 0)?[..2].try_into().unwrap())) + } + + fn get_macro_list(&mut self) -> Result, 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, String> { + let mut data = Vec::new(); + loop { + let mut args = Vec::with_capacity(70); + args.extend_from_slice(¯o_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(¯o_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 { + let mut args = Vec::with_capacity(6); + args.extend_from_slice(¯o_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(¯o_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, 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(¯o_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(¯o_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 { + 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(¯o_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, ¯o_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()) + } + + +} diff --git a/src-tauri/src/backend/device_parts/profile_led_button.rs b/src-tauri/src/backend/device_parts/profile_led_button.rs new file mode 100644 index 0000000..4ea06ee --- /dev/null +++ b/src-tauri/src/backend/device_parts/profile_led_button.rs @@ -0,0 +1,226 @@ +impl ConnectedDevice { + fn led_snapshot(&mut self, profile: &str) -> Result { + 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 { + Ok(self.sr_with(0x0f84, &[profile, region, 0], 0)?[2]) + } + + fn get_button_mapping_state( + &mut self, + profile: &str, + button: &str, + hypershift: bool, + ) -> Result { + 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 { + 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, 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 { + 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::>(); + 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(®ion.region)?; + let effect_value = led_effect_value(®ion.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 ®ion.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(()) + } + + +} diff --git a/src-tauri/src/backend/device_parts/sensor_io.rs b/src-tauri/src/backend/device_parts/sensor_io.rs new file mode 100644 index 0000000..f3a0980 --- /dev/null +++ b/src-tauri/src/backend/device_parts/sensor_io.rs @@ -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 { + 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 { + 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, 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, 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, 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 { + 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 { + 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) + } +} diff --git a/src-tauri/src/backend/discovery.rs b/src-tauri/src/backend/discovery.rs new file mode 100644 index 0000000..c9b0660 --- /dev/null +++ b/src-tauri/src/backend/discovery.rs @@ -0,0 +1,166 @@ +fn try_connect_summary( + summary: DeviceSummary, + logs: std::sync::Arc>>, +) -> 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 { + let mut candidates: Vec = 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 { + 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> { + 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)>> { + 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> { + 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 { + 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> { + 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) +} diff --git a/src-tauri/src/backend/ioctl.rs b/src-tauri/src/backend/ioctl.rs new file mode 100644 index 0000000..de9163f --- /dev/null +++ b/src-tauri/src/backend/ioctl.rs @@ -0,0 +1,33 @@ +fn hidraw_ioctl(fd: libc::c_int, request: libc::c_ulong, buffer: &mut [u8]) -> io::Result { + 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) +} diff --git a/src-tauri/src/backend/macro_ops.rs b/src-tauri/src/backend/macro_ops.rs new file mode 100644 index 0000000..9b5e3bb --- /dev/null +++ b/src-tauri/src/backend/macro_ops.rs @@ -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, 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, 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 { + 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) +} diff --git a/src-tauri/src/backend/models.rs b/src-tauri/src/backend/models.rs new file mode 100644 index 0000000..91e8d87 --- /dev/null +++ b/src-tauri/src/backend/models.rs @@ -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, + serial: Option, + interface_number: Option, + 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, + 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, +} + +#[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, + led: LedState, + profile_info_hex: Option, +} + +#[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, + operations: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SensorState { + lift_mode: String, + sensor_enabled: bool, + device_mode: String, + retrieved_calib: Vec, + param_a: Vec, + param_b: Vec, +} + +#[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>, + logs: std::sync::Arc>>, +} + +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>>, +} diff --git a/src-tauri/src/backend/value_maps.rs b/src-tauri/src/backend/value_maps.rs new file mode 100644 index 0000000..9f16be6 --- /dev/null +++ b/src-tauri/src/backend/value_maps.rs @@ -0,0 +1,187 @@ +fn profile_value(profile: &str) -> Result { + 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 { + match mode { + "tactile" => Ok(0), + "freespin" => Ok(1), + _ => Err(format!("Unsupported scroll mode '{mode}'")), + } +} + +fn led_region_value(region: &str) -> Result { + 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 { + 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 { + 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, String> { + let compact = input.chars().filter(|ch| !ch.is_whitespace()).collect::(); + 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 { + 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 { + 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 { + 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}'")), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..b87d7b0 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -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"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..9f8f8cb --- /dev/null +++ b/src-tauri/src/main.rs @@ -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() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..c73dd04 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -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" + ] + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..0aece34 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,26 @@ +use leptos::ev::{Event, KeyboardEvent}; +use leptos::prelude::*; +use leptos::task::spawn_local; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"], js_name = invoke)] + fn tauri_invoke(cmd: &str, args: JsValue) -> js_sys::Promise; +} + +include!("app/models.rs"); +include!("app/root.rs"); +include!("app/profile_panel.rs"); +include!("app/led_panel.rs"); +include!("app/basic_panel.rs"); +include!("app/button_panel.rs"); +include!("app/macro_panel.rs"); +include!("app/sensor_panel.rs"); +include!("app/button_editor_primary.rs"); +include!("app/button_editor_secondary.rs"); +include!("app/button_actions_info.rs"); +include!("app/helpers.rs"); diff --git a/src/app/basic_panel.rs b/src/app/basic_panel.rs new file mode 100644 index 0000000..4efd992 --- /dev/null +++ b/src/app/basic_panel.rs @@ -0,0 +1,267 @@ +#[component] +fn BasicPanel( + snapshot: DeviceState, + selected_profile: RwSignal, + set_snapshot: WriteSignal>, + set_status: WriteSignal, + set_busy: WriteSignal, +) -> impl IntoView { + let basic = snapshot.basic.clone(); + let initial_dpi_stages = basic.dpi_stages.clone(); + let initial_active_stage = basic.active_dpi_stage; + let stage_list_for_count = initial_dpi_stages.clone(); + let stage_list_for_active = initial_dpi_stages.clone(); + let stage_list_for_copy = initial_dpi_stages.clone(); + + let apply_dpi_stages = move |dpi_stages: Vec<[u16; 2]>, active_stage: u8| { + run_setting( + "set_dpi_stages", + DpiStagesArgs { + profile: selected_profile.get_untracked(), + dpi_stages, + active_stage, + }, + set_snapshot, + set_status, + set_busy, + ); + }; + + let apply_acceleration = move |ev: Event| { + run_setting( + "set_scroll_acceleration", + BoolSettingArgs { + profile: selected_profile.get_untracked(), + enabled: event_target_checked(&ev), + }, + set_snapshot, + set_status, + set_busy, + ); + }; + + let apply_smart_reel = move |ev: Event| { + run_setting( + "set_scroll_smart_reel", + BoolSettingArgs { + profile: selected_profile.get_untracked(), + enabled: event_target_checked(&ev), + }, + set_snapshot, + set_status, + set_busy, + ); + }; + + let apply_polling = move |ev: Event| { + let value = event_target_value(&ev).parse::().unwrap_or(1).max(1); + run_setting( + "set_polling_rate", + PollingRateArgs { + profile: selected_profile.get_untracked(), + polling_rate_ms: value, + }, + set_snapshot, + set_status, + set_busy, + ); + }; + + let set_dpi_x = move |ev: Event| { + let x = event_target_value(&ev) + .parse::() + .unwrap_or(basic.dpi_xy[0]); + run_setting( + "set_dpi_xy", + DpiArgs { + profile: selected_profile.get_untracked(), + x, + y: basic.dpi_xy[1], + }, + set_snapshot, + set_status, + set_busy, + ); + }; + + let set_dpi_y = move |ev: Event| { + let y = event_target_value(&ev) + .parse::() + .unwrap_or(basic.dpi_xy[1]); + run_setting( + "set_dpi_xy", + DpiArgs { + profile: selected_profile.get_untracked(), + x: basic.dpi_xy[0], + y, + }, + set_snapshot, + set_status, + set_busy, + ); + }; + + let set_stage_count = move |ev: Event| { + let requested = event_target_value(&ev) + .parse::() + .unwrap_or(stage_list_for_count.len()) + .clamp(1, 5); + let mut stages = stage_list_for_count.clone(); + stages.truncate(requested); + while stages.len() < requested { + stages.push([800, 800]); + } + let active_stage = initial_active_stage.min(requested as u8).max(1); + apply_dpi_stages(stages, active_stage); + }; + + let set_active_stage = move |ev: Event| { + let active_stage = event_target_value(&ev) + .parse::() + .unwrap_or(initial_active_stage) + .clamp(1, stage_list_for_active.len() as u8); + apply_dpi_stages(stage_list_for_active.clone(), active_stage); + }; + + let copy_stage_y_from_x = move |_| { + let stages = stage_list_for_copy + .iter() + .map(|[x, _]| [*x, *x]) + .collect::>(); + apply_dpi_stages(stages, initial_active_stage); + }; + + view! { +
+
+

"Scroll"

+ + + +
+ +
+

"Polling"

+

{format!("Report every {} ms.", basic.polling_rate_ms)}

+ +

{format!("{} Hz", 1000 / u16::from(basic.polling_rate_ms.max(1)))}

+
+ +
+

"DPI"

+
+ + +
+ "Active stage" +

{basic.active_dpi_stage}

+
+
+
+ + + +
+
+ {basic.dpi_stages.iter().enumerate().map(|(index, stage)| { + let stage_index = index; + let label = format!("Stage {}", stage_index + 1); + let stage_list_for_x = basic.dpi_stages.clone(); + let stage_list_for_y = basic.dpi_stages.clone(); + let active_stage_for_x = basic.active_dpi_stage; + let active_stage_for_y = basic.active_dpi_stage; + view! { +
+ {label} + + +
+ } + }).collect_view()} +
+
+
+ } +} diff --git a/src/app/button_actions_info.rs b/src/app/button_actions_info.rs new file mode 100644 index 0000000..f2ba4d4 --- /dev/null +++ b/src/app/button_actions_info.rs @@ -0,0 +1,226 @@ +#[component] +fn InfoPanel(snapshot: DeviceState) -> impl IntoView { + let debug_logs = RwSignal::new(Vec::::new()); + let info_state = RwSignal::new(None::); + let confirm_reset = RwSignal::new(false); + let raw_command = RwSignal::new("0x0082".to_string()); + let raw_args = RwSignal::new(String::new()); + let raw_response = RwSignal::new(String::new()); + let demo_mode = snapshot.device.path.starts_with("demo://"); + + let load_logs = move || { + if demo_mode { + debug_logs.set(vec![DebugLogEntry { + timestamp_ms: 0, + level: "info".to_string(), + message: "Demo mode active; no hidraw traffic available.".to_string(), + }]); + return; + } + spawn_local(async move { + if let Ok(logs) = invoke_no_args::>("get_debug_logs").await { + debug_logs.set(logs); + } + }); + }; + + Effect::new(move |_| load_logs()); + + let load_info = move || { + if demo_mode { + info_state.set(Some(InfoState { + flash_total: 4096, + flash_free: 2048, + flash_recycled: 256, + macro_count: 2, + })); + return; + } + spawn_local(async move { + if let Ok(state) = invoke_no_args::("get_info_state").await { + info_state.set(Some(state)); + } + }); + }; + + Effect::new(move |_| load_info()); + + view! { +
+
+

"Device Info"

+
+
"Model"
{snapshot.device.supported_name}
+
"Product"
{snapshot.device.product_name}
+
"Manufacturer"
{snapshot.device.manufacturer.unwrap_or_else(|| "Unknown".to_string())}
+
"Serial"
{snapshot.serial}
+
"USB ID"
{format!("1532:{:04x}", snapshot.device.product_id)}
+
"Firmware"
{snapshot.firmware}
+
"HID path"
{snapshot.device.path}
+
"Interface"
{snapshot.device.interface_number.map(|value| value.to_string()).unwrap_or_else(|| "Unknown".to_string())}
+
+ + {move || info_state.get().map(|info| { + let flash_used = info.flash_total.saturating_sub(info.flash_free); + let flash_can_use = info.flash_free.saturating_sub(info.flash_recycled); + let flash_total = info.flash_total.max(1); + let used_pct = flash_used.saturating_mul(100) / flash_total; + let avail_pct = flash_can_use.saturating_mul(100) / flash_total; + let recycled_pct = info.flash_recycled.saturating_mul(100) / flash_total; + view! { +
+
"Flash total"
{info.flash_total}
+
"Flash used"
{flash_used}
+
"Flash available"
{flash_can_use}
+
"Flash recycled"
{info.flash_recycled}
+
"Macro count"
{info.macro_count}
+
+ +
+ {format!("{} in use", flash_used / 256)} + {format!("{} available", flash_can_use / 256)} + {format!("{} recycled", info.flash_recycled / 256)} +
+
+ {if confirm_reset.get() { + view! { + + }.into_any() + } else { + view! { + + }.into_any() + }} +
+ } + })} +
+ +
+

"Raw Report"

+
+ +