diff --git a/CLAUDE.md b/CLAUDE.md index b611d5a..20dba59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,11 @@ delegates events, so synthetic events need `{bubbles: true}`. multimedia key layouts. It is embedded with `include_str!` and parsed once (`core::profile::catalog()`). - Python dict keys that are ints (`choices`, `output_choices`) are exported as lists to keep their type and order. - - Battery lambdas become `battery_level.format: "aerox" | "rival"`. + - Battery lambdas become `battery_level.format: "aerox" | "rival" | "rival_pro"`, and a firmware + `parse` lambda becomes `firmware_version.format: "ascii"` (no format = raw bytes). Both are + identified by probing the lambdas; an unknown one fails the export. + - `output_choices` tables that map every `input_range` value to itself (the Rival Pro's 65000 DPI) are + exported as `output_choices_identity: true` and rebuilt by `Setting::expand_output_choices()`. - `src-tauri/resources/99-steelseries-rival.rules`, the udev rules. They are embedded for the in-app installer and shipped in the .deb/.rpm. - `tools/gen_golden.py` drives rivalcfg's `Mouse` + `FakeDevice` over every PID × setting × representative diff --git a/src-tauri/resources/99-steelseries-rival.rules b/src-tauri/resources/99-steelseries-rival.rules index 9bc146c..2a2588d 100644 --- a/src-tauri/resources/99-steelseries-rival.rules +++ b/src-tauri/resources/99-steelseries-rival.rules @@ -143,6 +143,10 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="183c", MODE="0666" SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="183e", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="183e", MODE="0666" +# SteelSeries Rival Pro +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="1880", MODE="0666" +SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="1880", MODE="0666" + # SteelSeries Rival 95 SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="1706", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="1706", MODE="0666" diff --git a/src-tauri/resources/profiles.json b/src-tauri/resources/profiles.json index 8e450b6..1717fa7 100644 --- a/src-tauri/resources/profiles.json +++ b/src-tauri/resources/profiles.json @@ -27941,6 +27941,537 @@ ] } }, + { + "name": "SteelSeries Rival Pro", + "vendor_id": 4152, + "product_id": 6272, + "endpoint": 2, + "settings": [ + { + "name": "sensitivity", + "label": "Sensitivity presets", + "description": "Set sensitivity presets (DPI)", + "cli": [ + "-s", + "--sensitivity" + ], + "report_type": 2, + "command": [ + 52 + ], + "value_type": "multidpi_range_choice_xy", + "input_range": [ + 1, + 65000, + 1 + ], + "output_choices_identity": true, + "xy_mapping": "xyxy", + "dpi_length_byte": 2, + "preset_suffix": [ + 3, + 3 + ], + "first_preset": 0, + "max_preset_count": 5, + "default": "400, 800, 1600, 2400, 3200" + }, + { + "name": "polling_rate", + "label": "Polling rate", + "description": "Set polling rate (Hz)", + "cli": [ + "-p", + "--polling-rate" + ], + "report_type": 2, + "command": [ + 45, + 1, + 0 + ], + "value_type": "choice", + "choices": [ + { + "key": 125, + "value": [ + 6, + 6 + ] + }, + { + "key": 250, + "value": [ + 5, + 5 + ] + }, + { + "key": 500, + "value": [ + 4, + 4 + ] + }, + { + "key": 1000, + "value": [ + 3, + 3 + ] + }, + { + "key": 2000, + "value": [ + 2, + 2 + ] + }, + { + "key": 4000, + "value": [ + 1, + 1 + ] + }, + { + "key": 8000, + "value": [ + 0, + 0 + ] + } + ], + "default": 1000 + }, + { + "name": "buttons_mapping", + "label": "Buttons mapping", + "description": "Set the mapping of the buttons", + "cli": [ + "-b", + "--buttons" + ], + "report_type": 2, + "command": [ + 42 + ], + "value_type": "buttons", + "buttons": [ + { + "name": "Button1", + "id": 1, + "offset": 0, + "default": "button1" + }, + { + "name": "Button2", + "id": 2, + "offset": 5, + "default": "button2" + }, + { + "name": "Button3", + "id": 3, + "offset": 10, + "default": "button3" + }, + { + "name": "Button4", + "id": 4, + "offset": 15, + "default": "button4" + }, + { + "name": "Button5", + "id": 5, + "offset": 20, + "default": "button5" + }, + { + "name": "Button6", + "id": 6, + "offset": 25, + "default": "dpi" + }, + { + "name": "ScrollUp", + "id": 49, + "offset": 30, + "default": "scrollup" + }, + { + "name": "ScrollDown", + "id": 50, + "offset": 35, + "default": "scrolldown" + } + ], + "button_field_length": 5, + "button_disable": 0, + "button_keyboard": 81, + "button_multimedia": 97, + "button_dpi_switch": 48, + "button_scroll_up": null, + "button_scroll_down": null, + "default": "buttons(button1=button1; button2=button2; button3=button3; button4=button4; button5=button5; button6=dpi; scrollup=scrollup; scrolldown=scrolldown; layout=qwerty)" + }, + { + "name": "sleep_timer", + "label": "Sleep timer", + "description": "Set the IDLE time before the mouse goes to sleep mode (minutes)", + "cli": [ + "-t", + "--sleep-timer" + ], + "report_type": 2, + "command": [ + 41 + ], + "value_type": "range", + "input_range": [ + 1, + 20, + 1 + ], + "output_range": [ + 60000, + 1200000, + 60000 + ], + "range_length_byte": 3, + "default": 5 + }, + { + "name": "click_response", + "label": "Click response", + "description": "Set the click response time (debounce, ms)", + "cli": [ + "--click-response" + ], + "report_type": 2, + "command": [ + 28 + ], + "value_type": "range", + "input_range": [ + 0, + 20, + 1 + ], + "output_range": [ + 0, + 20, + 1 + ], + "default": 8 + }, + { + "name": "scroll_jump_protection", + "label": "Scroll jump protection", + "description": "Ignore the first scroll input after a cooldown period (ms)", + "cli": [ + "--scroll-jump-protection" + ], + "report_type": 2, + "command": [ + 22 + ], + "value_type": "choice", + "choices": [ + { + "key": "off", + "value": [ + 0, + 244, + 1 + ] + }, + { + "key": 100, + "value": [ + 1, + 100, + 0 + ] + }, + { + "key": 200, + "value": [ + 1, + 200, + 0 + ] + }, + { + "key": 300, + "value": [ + 1, + 44, + 1 + ] + }, + { + "key": 400, + "value": [ + 1, + 144, + 1 + ] + }, + { + "key": 500, + "value": [ + 1, + 244, + 1 + ] + }, + { + "key": 600, + "value": [ + 1, + 88, + 2 + ] + }, + { + "key": 700, + "value": [ + 1, + 188, + 2 + ] + }, + { + "key": 800, + "value": [ + 1, + 32, + 3 + ] + }, + { + "key": 900, + "value": [ + 1, + 132, + 3 + ] + }, + { + "key": 1000, + "value": [ + 1, + 232, + 3 + ] + }, + { + "key": 1100, + "value": [ + 1, + 76, + 4 + ] + }, + { + "key": 1200, + "value": [ + 1, + 176, + 4 + ] + }, + { + "key": 1300, + "value": [ + 1, + 20, + 5 + ] + }, + { + "key": 1400, + "value": [ + 1, + 120, + 5 + ] + }, + { + "key": 1500, + "value": [ + 1, + 220, + 5 + ] + } + ], + "default": "off" + }, + { + "name": "motion_sync", + "label": "Motion sync", + "description": "Enable motion sync (smoother tracking for slow movements)", + "cli": [ + "--motion-sync" + ], + "report_type": 2, + "command": [ + 24 + ], + "value_type": "choice", + "choices": [ + { + "key": "on", + "value": 1 + }, + { + "key": "off", + "value": 0 + } + ], + "default": "off" + }, + { + "name": "lan_mode", + "label": "LAN mode", + "description": "Enable LAN mode (when multiple UWB mice are nearby, must be enabled on all mice)", + "cli": [ + "--lan-mode" + ], + "report_type": 2, + "command": [ + 26 + ], + "value_type": "choice", + "choices": [ + { + "key": "on", + "value": 1 + }, + { + "key": "off", + "value": 0 + } + ], + "default": "off" + }, + { + "name": "max_performance", + "label": "Max performance", + "description": "Run the sensor in its highest performance mode all the time", + "cli": [ + "--max-performance" + ], + "report_type": 2, + "command": [ + 27 + ], + "value_type": "choice", + "choices": [ + { + "key": "on", + "value": 1 + }, + { + "key": "off", + "value": 0 + } + ], + "default": "off" + }, + { + "name": "led_indicator", + "label": "LED indicator", + "description": "Set what the receiver LED indicates", + "cli": [ + "--led-indicator" + ], + "report_type": 2, + "command": [ + 37 + ], + "value_type": "choice", + "choices": [ + { + "key": "polling-rate", + "value": 0 + }, + { + "key": "battery", + "value": 1 + }, + { + "key": "off", + "value": 2 + } + ], + "default": "polling-rate" + }, + { + "name": "low_power_polling_rate", + "label": "Low power mode polling rate", + "description": "Set the polling rate used when the battery is low (Hz, or 'led-only' to only warn using the LED)", + "cli": [ + "--low-power-polling-rate" + ], + "report_type": 2, + "command": [ + 25, + 0, + 5 + ], + "value_type": "choice", + "choices": [ + { + "key": 125, + "value": 6 + }, + { + "key": 250, + "value": 5 + }, + { + "key": 500, + "value": 4 + }, + { + "key": 1000, + "value": 3 + }, + { + "key": "led-only", + "value": 7 + } + ], + "default": 1000 + } + ], + "save_command": { + "report_type": 2, + "command": [ + 9 + ] + }, + "firmware_version": { + "report_type": 2, + "command": [ + 144 + ], + "response_length": 32, + "format": "ascii" + }, + "battery_level": { + "report_type": 2, + "command": [ + 146 + ], + "response_length": 5, + "format": "rival_pro" + } + }, { "name": "SteelSeries Rival 95", "vendor_id": 4152, diff --git a/src-tauri/src/core/device.rs b/src-tauri/src/core/device.rs index d916896..67abdcc 100644 --- a/src-tauri/src/core/device.rs +++ b/src-tauri/src/core/device.rs @@ -6,7 +6,7 @@ use serde::Serialize; use serde_json::Value; use super::encode::{self, Packet}; -use super::profile::{BatteryFormat, Profile, Setting}; +use super::profile::{BatteryFormat, FirmwareFormat, Profile, Setting}; use super::pyval::Result; /// Delay between two commands so the device is not overloaded (Kinzu v2 & co). @@ -41,6 +41,7 @@ pub fn parse_battery(format: BatteryFormat, data: &[u8]) -> Battery { None => (None, None), }, BatteryFormat::Rival => (data.first().map(|b| *b as i64), data.get(2).map(|b| *b != 0)), + BatteryFormat::RivalPro => (data.get(2).map(|b| *b as i64), None), }; match level { Some(l) if (0..=100).contains(&l) => Battery { level: Some(l as u8), charging }, @@ -48,6 +49,27 @@ pub fn parse_battery(format: BatteryFormat, data: &[u8]) -> Battery { } } +/// Decodes a firmware version response (`Mouse.firmware_version`). +pub fn parse_firmware_version(format: FirmwareFormat, data: &[u8]) -> Option { + if data.is_empty() { + return None; + } + match format { + FirmwareFormat::Raw => Some(data.iter().map(|b| b.to_string()).collect::>().join(".")), + FirmwareFormat::Ascii => { + let text = data.get(2..).unwrap_or_default(); + let text = &text[..text.iter().position(|b| *b == 0).unwrap_or(text.len())]; + // Same as rivalcfg: "2.14.2" -> (2, 14, 2) -> "2.14.2" + let parts: Option> = std::str::from_utf8(text) + .ok()? + .split('.') + .map(|n| n.parse().ok()) + .collect(); + Some(parts?.iter().map(|n| n.to_string()).collect::>().join(".")) + } + } +} + pub struct Device { pub profile: &'static Profile, transport: Box, @@ -99,9 +121,7 @@ impl Device { let packet = encode::command_packet(cmd.report_type, &cmd.command, 0)?; self.send(&packet)?; let data = self.transport.read(cmd.response_length, READ_TIMEOUT_MS)?; - Ok((!data.is_empty()).then(|| { - data.iter().map(|b| b.to_string()).collect::>().join(".") - })) + Ok(parse_firmware_version(cmd.format, &data)) } } @@ -118,5 +138,18 @@ mod tests { assert_eq!(parse_battery(BatteryFormat::Aerox, &[]), Battery::default()); assert_eq!(parse_battery(BatteryFormat::Aerox, &[0, 0x00]), Battery::default()); assert_eq!(parse_battery(BatteryFormat::Rival, &[200, 0, 1]), Battery::default()); + let b = parse_battery(BatteryFormat::RivalPro, &[0x92, 0x00, 87, 0x01, 52]); + assert_eq!(b, Battery { level: Some(87), charging: None }); + assert_eq!(parse_battery(BatteryFormat::RivalPro, &[0x92, 0x00]), Battery::default()); + } + + #[test] + fn firmware_formats() { + assert_eq!(parse_firmware_version(FirmwareFormat::Raw, &[1, 33]), Some("1.33".into())); + assert_eq!(parse_firmware_version(FirmwareFormat::Raw, &[]), None); + let mut data = b"\x90\x002.14.2".to_vec(); + data.resize(32, 0); + assert_eq!(parse_firmware_version(FirmwareFormat::Ascii, &data), Some("2.14.2".into())); + assert_eq!(parse_firmware_version(FirmwareFormat::Ascii, b"\x90\x00garbage"), None); } } diff --git a/src-tauri/src/core/encode/range_choice.rs b/src-tauri/src/core/encode/range_choice.rs index 7d17ebc..d4d4a9c 100644 --- a/src-tauri/src/core/encode/range_choice.rs +++ b/src-tauri/src/core/encode/range_choice.rs @@ -132,6 +132,10 @@ pub fn process_multidpi_xy(setting: &Setting, value: &Value, selected_preset: Op let dpi_length = setting .dpi_length_byte .ok_or("Missing 'dpi_length_byte' parameter for 'multidpi_range_choice_xy' handler")?; + let suffix = setting.preset_suffix.as_deref().unwrap_or_default(); + if !suffix.is_empty() && mapping != "xyxy" { + return Err("'preset_suffix' parameter is only supported with 'xyxy' xy-mapping".into()); + } let mut pairs = Vec::new(); for [x, y] in &dpis { @@ -147,6 +151,7 @@ pub fn process_multidpi_xy(setting: &Setting, value: &Value, selected_preset: Op for (x, y) in pairs { out.extend(x); out.extend(y); + out.extend(suffix.iter().map(|b| *b as i64)); } } "xxyy" => { @@ -181,4 +186,27 @@ mod tests { assert_eq!(normalize_xy(&v).unwrap(), vec![[100, 100], [100, 150], [200, 200]]); assert!(normalize_xy(&Value::from("1:2:3")).is_err()); } + + #[test] + fn preset_suffix() { + let mut setting: Setting = serde_json::from_value(serde_json::json!({ + "name": "sensitivity", + "report_type": 2, + "command": [0x34], + "value_type": "multidpi_range_choice_xy", + "input_range": [1, 65000, 1], + "output_choices_identity": true, + "xy_mapping": "xyxy", + "dpi_length_byte": 2, + "preset_suffix": [3, 3], + "max_preset_count": 5, + })) + .unwrap(); + setting.expand_output_choices(); + let out = process_multidpi_xy(&setting, &Value::from("800, 1200:1600"), Some(1)).unwrap(); + assert_eq!(out, [2, 1, 0x20, 3, 0x20, 3, 3, 3, 0xB0, 4, 0x40, 6, 3, 3]); + assert_eq!(process_multidpi_xy(&setting, &Value::from(70000), None).unwrap(), [1, 0, 0xE8, 0xFD, 0xE8, 0xFD, 3, 3]); + setting.xy_mapping = Some("xxyy".into()); + assert!(process_multidpi_xy(&setting, &Value::from(800), None).is_err()); + } } diff --git a/src-tauri/src/core/profile.rs b/src-tauri/src/core/profile.rs index fdad5fd..8de0a10 100644 --- a/src-tauri/src/core/profile.rs +++ b/src-tauri/src/core/profile.rs @@ -14,8 +14,12 @@ static CATALOG: OnceLock = OnceLock::new(); /// Returns the catalog of all supported devices (parsed once). pub fn catalog() -> &'static Catalog { CATALOG.get_or_init(|| { - serde_json::from_str(include_str!("../../resources/profiles.json")) - .expect("embedded profiles.json is invalid") + let mut catalog: Catalog = serde_json::from_str(include_str!("../../resources/profiles.json")) + .expect("embedded profiles.json is invalid"); + for setting in catalog.profiles.iter_mut().flat_map(|p| p.settings.iter_mut()) { + setting.expand_output_choices(); + } + catalog }) } @@ -80,15 +84,30 @@ pub struct QueryCommand { pub report_type: u8, pub command: Vec, pub response_length: usize, + #[serde(default)] + pub format: FirmwareFormat, +} + +/// How a firmware version response is decoded (the `parse` lambdas of rivalcfg profiles). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FirmwareFormat { + /// Every byte is a version number + #[default] + Raw, + /// ` 00 `, e.g. "2.14.2" + Ascii, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum BatteryFormat { /// level = ((d[1] & 0x7F) - 1) * 5, charging = d[1] & 0x80 Aerox, /// level = d[0], charging = d[2] Rival, + /// level = d[2], no charging state (swappable battery) + RivalPro, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -162,6 +181,10 @@ pub struct Setting { /// range_choice handlers: (input DPI, device value) table of the sensor #[serde(default)] pub output_choices: Option>, + /// `output_choices` maps every value of `input_range` to itself (exported as a flag to keep + /// profiles.json small, see [`Setting::expand_output_choices`]) + #[serde(default, skip_serializing)] + pub output_choices_identity: bool, /// multidpi_range_choice_xy: "xyxy" or "xxyy" #[serde(default)] pub xy_mapping: Option, @@ -169,6 +192,9 @@ pub struct Setting { pub range_length_byte: Option, #[serde(default)] pub dpi_length_byte: Option, + /// multidpi_range_choice_xy ("xyxy" only): bytes appended after each preset + #[serde(default)] + pub preset_suffix: Option>, #[serde(default)] pub first_preset: Option, #[serde(default)] @@ -206,6 +232,16 @@ impl Setting { self.value_type.as_deref().unwrap_or("") } + /// Rebuilds an identity `output_choices` table from `input_range`. + pub fn expand_output_choices(&mut self) { + if let (true, Some([first, last, step])) = (self.output_choices_identity, self.input_range) { + if step > 0 { + self.output_choices = Some((first..=last).step_by(step as usize).map(|v| (v, v)).collect()); + } + self.output_choices_identity = false; + } + } + /// Settings without a value (e.g. "enable rainbow effect") are one-shot actions. pub fn is_action(&self) -> bool { matches!(self.value_type(), "none" | "") diff --git a/src-tauri/src/core/view.rs b/src-tauri/src/core/view.rs index 17b5ffc..4332c8d 100644 --- a/src-tauri/src/core/view.rs +++ b/src-tauri/src/core/view.rs @@ -67,11 +67,22 @@ pub struct SettingView { } pub fn group_of(name: &str) -> &'static str { - if name.starts_with("sensitivity") || name == "polling_rate" { + if name.starts_with("sensitivity") + || matches!( + name, + "polling_rate" + | "lift_off_distance" + | "click_response" + | "scroll_jump_protection" + | "motion_sync" + | "lan_mode" + | "max_performance" + ) + { "performance" } else if name.starts_with("btn") || name.starts_with("buttons") { "buttons" - } else if name.ends_with("_timer") { + } else if name.ends_with("_timer") || name.starts_with("low_power_") || name == "led_indicator" { "power" } else { "lighting" diff --git a/src-tauri/tests/golden.json b/src-tauri/tests/golden.json index 846c14c..64cda7a 100644 --- a/src-tauri/tests/golden.json +++ b/src-tauri/tests/golden.json @@ -19126,6 +19126,714 @@ }, { "vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": "400, 800, 1600, 2400, 3200", +"bytes": "0200340500900190010303200320030303400640060303600960090303800c800c0303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": 1, +"bytes": "0200340100010001000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": 65000, +"bytes": "0200340100e8fde8fd0303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": "2:8, 8:2", +"bytes": "0200340200020008000303080002000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": "2, 8:2", +"bytes": "0200340200020002000303080002000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +[ +2, +8 +], +[ +8, +2 +] +], +"bytes": "0200340200020008000303080002000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +2, +[ +8, +2 +] +], +"bytes": "0200340200020002000303080002000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +[ +2 +] +], +"bytes": "0200340100020002000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": "1:65000, 2:64999, 3:64998, 4:64997, 5:64996", +"bytes": "02003405000100e8fd03030200e7fd03030300e6fd03030400e5fd03030500e4fd0303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +1, +1, +1, +1, +1, +1 +], +"error": "ValueError: you provided 6 preset but the device accepts a maximum of 5 presets" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": "2:2:2", +"error": "ValueError: too many values to unpack (expected 2, got 3)" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +1, +2, +3 +], +"selected_preset": 0, +"bytes": "0200340300010001000303020002000303030003000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +1, +2, +3 +], +"selected_preset": 1, +"bytes": "0200340301010001000303020002000303030003000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +1, +2, +3 +], +"selected_preset": 2, +"bytes": "0200340302010001000303020002000303030003000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sensitivity", +"value": [ +1, +2, +3 +], +"selected_preset": 3, +"error": "ValueError: the selected preset is out of range" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 1000, +"bytes": "02002d01000303" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 125, +"bytes": "02002d01000606" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 250, +"bytes": "02002d01000505" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 500, +"bytes": "02002d01000404" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 2000, +"bytes": "02002d01000202" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 4000, +"bytes": "02002d01000101" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": 8000, +"bytes": "02002d01000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "polling_rate", +"value": "__invalid__", +"error": "ValueError: value must be one of [125, 250, 500, 1000, 2000, 4000, 8000]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(button1=button1; button2=button2; button3=button3; button4=button4; button5=button5; button6=dpi; scrollup=scrollup; scrolldown=scrolldown; layout=qwerty)", +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000003200000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "default", +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000003200000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(Button1=Button2; Button2=Button1)", +"bytes": "02002a02000000000100000000030000000004000000000500000000300000000031000000003200000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(ScrollDown=disabled)", +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000000000000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(Button1=dpi)", +"bytes": "02002a30000000000200000000030000000004000000000500000000300000000031000000003200000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=A)", +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000005104000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=z)", +"bytes": "02002a0100000000020000000003000000000400000000050000000030000000003100000000511d000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=F5)", +"bytes": "02002a0100000000020000000003000000000400000000050000000030000000003100000000513e000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=Enter)", +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000005128000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=semicolon)", +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000005133000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=Space)", +"bytes": "02002a0100000000020000000003000000000400000000050000000030000000003100000000512c000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(layout=qwerty; ScrollDown=LeftCtrl)", +"bytes": "02002a010000000002000000000300000000040000000005000000003000000000310000000051e0000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(ScrollDown=PlayPause)", +"bytes": "02002a010000000002000000000300000000040000000005000000003000000000310000000061cd000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(ScrollDown=VolumeUp)", +"bytes": "02002a010000000002000000000300000000040000000005000000003000000000310000000061e9000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(ScrollDown=Mute)", +"bytes": "02002a010000000002000000000300000000040000000005000000003000000000310000000061e2000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": { +"buttons": { +"scrolldown": "button1" +} +}, +"bytes": "02002a01000000000200000000030000000004000000000500000000300000000031000000000100000000" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(nosuchbutton=button1)", +"error": "ValueError: This mouse have no button named 'nosuchbutton'" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "buttons_mapping", +"value": "buttons(ScrollDown=nosuchkey)", +"error": "ValueError: Unknown button, key or action 'nosuchkey'" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 5, +"bytes": "020029e09304" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 1, +"bytes": "02002960ea00" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 20, +"bytes": "020029804f12" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 10, +"bytes": "020029c02709" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 2, +"bytes": "020029c0d401" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 11, +"bytes": "02002920120a" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 0, +"bytes": "02002960ea00" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "sleep_timer", +"value": 21, +"bytes": "020029804f12" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 8, +"bytes": "02001c08" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 0, +"bytes": "02001c00" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 20, +"bytes": "02001c14" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 10, +"bytes": "02001c0a" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 1, +"bytes": "02001c01" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 11, +"bytes": "02001c0b" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": -1, +"bytes": "02001c00" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "click_response", +"value": 21, +"bytes": "02001c14" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": "off", +"bytes": "02001600f401" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 100, +"bytes": "020016016400" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 200, +"bytes": "02001601c800" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 300, +"bytes": "020016012c01" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 400, +"bytes": "020016019001" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 500, +"bytes": "02001601f401" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 600, +"bytes": "020016015802" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 700, +"bytes": "02001601bc02" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 800, +"bytes": "020016012003" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 900, +"bytes": "020016018403" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 1000, +"bytes": "02001601e803" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 1100, +"bytes": "020016014c04" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 1200, +"bytes": "02001601b004" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 1300, +"bytes": "020016011405" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 1400, +"bytes": "020016017805" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": 1500, +"bytes": "02001601dc05" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "scroll_jump_protection", +"value": "__invalid__", +"error": "ValueError: value must be one of [off, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "motion_sync", +"value": "off", +"bytes": "02001800" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "motion_sync", +"value": "on", +"bytes": "02001801" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "motion_sync", +"value": "__invalid__", +"error": "ValueError: value must be one of [on, off]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "lan_mode", +"value": "off", +"bytes": "02001a00" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "lan_mode", +"value": "on", +"bytes": "02001a01" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "lan_mode", +"value": "__invalid__", +"error": "ValueError: value must be one of [on, off]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "max_performance", +"value": "off", +"bytes": "02001b00" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "max_performance", +"value": "on", +"bytes": "02001b01" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "max_performance", +"value": "__invalid__", +"error": "ValueError: value must be one of [on, off]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "led_indicator", +"value": "polling-rate", +"bytes": "02002500" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "led_indicator", +"value": "battery", +"bytes": "02002501" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "led_indicator", +"value": "off", +"bytes": "02002502" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "led_indicator", +"value": "__invalid__", +"error": "ValueError: value must be one of [polling-rate, battery, off]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "low_power_polling_rate", +"value": 1000, +"bytes": "020019000503" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "low_power_polling_rate", +"value": 125, +"bytes": "020019000506" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "low_power_polling_rate", +"value": 250, +"bytes": "020019000505" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "low_power_polling_rate", +"value": 500, +"bytes": "020019000504" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "low_power_polling_rate", +"value": "led-only", +"bytes": "020019000507" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "low_power_polling_rate", +"value": "__invalid__", +"error": "ValueError: value must be one of [led-only, 125, 250, 500, 1000]" +}, +{ +"vid": 4152, +"pid": 6272, +"setting": "__save__", +"value": null, +"bytes": "020009" +}, +{ +"vid": 4152, "pid": 5894, "setting": "sensitivity1", "value": 1000, diff --git a/src/lib/DevicePanel.svelte b/src/lib/DevicePanel.svelte index df6e662..4a2734e 100644 --- a/src/lib/DevicePanel.svelte +++ b/src/lib/DevicePanel.svelte @@ -88,8 +88,8 @@

{name.title}

- - {name.mode ? td(name.mode) : t("wired")} + + {name.mode ? td(name.mode) : status.wireless ? t("wireless") : t("wired")} {#if status.firmware}{t("firmware", { version: status.firmware })}{/if} diff --git a/src/lib/SettingRow.svelte b/src/lib/SettingRow.svelte index e4b2ab2..8c88db0 100644 --- a/src/lib/SettingRow.svelte +++ b/src/lib/SettingRow.svelte @@ -35,7 +35,8 @@ let pending: Json | undefined; const unit = $derived.by(() => { - const m = setting.description.match(/\((minutes|seconds|ms|dpi|hz|%)/i); + // "(ms)", "(minutes, 0 = disable)", "(debounce, ms)" + const m = setting.description.match(/\((?:[^)]*,\s*)?(minutes|seconds|ms|dpi|hz|%)/i); if (!m) return ""; return { minutes: "min", seconds: "s" }[m[1].toLowerCase()] ?? m[1]; }); diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index bf7749b..1e4089f 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -11,6 +11,7 @@ export const en = { // Device header wired: "Wired", + wireless: "Wireless", firmware: "Firmware {version}", reapply: "Re-apply", reapplyTitle: "Send every setting of this profile to the mouse again", diff --git a/src/lib/i18n/pl.ts b/src/lib/i18n/pl.ts index 02b92dc..34e31b0 100644 --- a/src/lib/i18n/pl.ts +++ b/src/lib/i18n/pl.ts @@ -11,6 +11,7 @@ export const pl: Record = { hidError: "Błąd HID: {error}", wired: "Przewodowa", + wireless: "Bezprzewodowa", firmware: "Oprogramowanie {version}", reapply: "Zastosuj ponownie", reapplyTitle: "Wyślij ponownie wszystkie ustawienia tego profilu do myszy", @@ -123,12 +124,15 @@ export const plData: Record = { // Setting labels "Button 6 mode": "Tryb przycisku 6", "Buttons mapping": "Przypisanie przycisków", + "Click response": "Czas reakcji przycisków", "Default lighting": "Domyślne podświetlenie", "Dim timer": "Czas do przyciemnienia", "LED Brightness": "Jasność LED", "LED Brightness 1": "Jasność LED 1", "LED Brightness 2": "Jasność LED 2", "LED color": "Kolor LED", + "LED indicator": "Wskaźnik LED", + "LAN mode": "Tryb LAN", "Left strip, bottom LED color": "Lewy pasek, kolor dolnej diody", "Left strip, bottom LED colors and effects": "Lewy pasek, kolory i efekty dolnej diody", "Left strip, middle LED colors and effects": "Lewy pasek, kolory i efekty środkowej diody", @@ -136,10 +140,14 @@ export const plData: Record = { "Left strip, middle top LED color": "Lewy pasek, kolor środkowej górnej diody", "Left strip, top LED color": "Lewy pasek, kolor górnej diody", "Left strip, top LED colors and effects": "Lewy pasek, kolory i efekty górnej diody", + "Lift-off distance": "Wysokość podniesienia (LOD)", "Light effect": "Efekt świetlny", "Logo LED color": "Kolor podświetlenia logo", "Logo LED colors and effects": "Kolory i efekty podświetlenia logo", "Logo light effect": "Efekt świetlny logo", + "Low power mode polling rate": "Częstotliwość odświeżania w trybie oszczędzania energii", + "Max performance": "Maksymalna wydajność", + "Motion sync": "Motion Sync", "Polling rate": "Częstotliwość odświeżania", "Reactive color": "Kolor reakcji", "Right strip, bottom LED color": "Prawy pasek, kolor dolnej diody", @@ -149,6 +157,7 @@ export const plData: Record = { "Right strip, middle top LED color": "Prawy pasek, kolor środkowej górnej diody", "Right strip, top LED color": "Prawy pasek, kolor górnej diody", "Right strip, top LED colors and effects": "Prawy pasek, kolory i efekty górnej diody", + "Scroll jump protection": "Ochrona przed przeskokiem kółka", "Sensitivity preset 1": "Czułość, ustawienie 1", "Sensitivity preset 2": "Czułość, ustawienie 2", "Sensitivity presets": "Ustawienia czułości", @@ -213,9 +222,18 @@ export const plData: Record = { "Set the rainbow effect": "Włącza efekt tęczy", "Set the wheel colors and effects": "Kolory i efekty kółka", "Set the polling rate": "Częstotliwość raportowania pozycji do komputera", + "Enable LAN mode": "Włącza tryb LAN", + "Enable motion sync": "Włącza Motion Sync", + "Ignore the first scroll input after a cooldown period": "Ignoruje pierwszy ruch kółka po okresie bezczynności", + "Run the sensor in its highest performance mode all the time": "Sensor stale pracuje w trybie najwyższej wydajności", + "Set the click response time": "Czas eliminacji drgań styków przycisków", + "Set the lift-off distance": "Wysokość, na której sensor przestaje śledzić ruch", + "Set the polling rate used when the battery is low": "Częstotliwość odświeżania przy niskim poziomie baterii", + "Set what the receiver LED indicates": "Co pokazuje dioda odbiornika", // Choice values all: "Wszystkie", + battery: "Bateria", bottom: "Dół", "bottom-middle": "Dół i środek", "bottom-top": "Dół i góra", @@ -225,12 +243,15 @@ export const plData: Record = { disco: "Disco", dpi: "DPI", high: "Wysoka", + "led-only": "Tylko LED", low: "Niska", medium: "Średnia", middle: "Środek", "middle-top": "Środek i góra", off: "Wyłączone", + on: "Włączone", os: "System", + "polling-rate": "Częstotliwość odświeżania", rainbow: "Tęcza", "rainbow-breath": "Tęcza (oddychanie)", "rainbow-shift": "Tęcza (przejście)", diff --git a/tools/export_profiles.py b/tools/export_profiles.py index 7bd3dd6..88a7fbe 100644 --- a/tools/export_profiles.py +++ b/tools/export_profiles.py @@ -28,13 +28,24 @@ "aerox": (20, True), # d[0], charging = d[2] "rival": (16, True), + # d[2], no charging state (swappable battery) + "rival_pro": (1, None), +} + +# Probe data used to identify which parser a profile uses for the firmware version +_FIRMWARE_PROBE = list(b"\x90\x001.2.3".ljust(32, b"\0")) +_FIRMWARE_FORMATS = { + # " 00 ", e.g. "2.14.2" + "ascii": [1, 2, 3], } def battery_format(battery_level): result = ( battery_level["level"](_BATTERY_PROBE), - battery_level["is_charging"](_BATTERY_PROBE), + battery_level["is_charging"](_BATTERY_PROBE) + if "is_charging" in battery_level + else None, ) for name, expected in _BATTERY_FORMATS.items(): if result == expected: @@ -42,6 +53,14 @@ def battery_format(battery_level): raise ValueError("Unknown battery formula (probe result: %r)" % (result,)) +def firmware_format(firmware_version): + result = list(firmware_version["parse"](_FIRMWARE_PROBE)) + for name, expected in _FIRMWARE_FORMATS.items(): + if result == expected: + return name + raise ValueError("Unknown firmware version parser (probe result: %r)" % (result,)) + + def export_setting(name, info): setting = {"name": name} for key, value in info.items(): @@ -49,8 +68,14 @@ def export_setting(name, info): # JSON objects only have string keys: keep the original key type and order setting["choices"] = [{"key": k, "value": v} for k, v in value.items()] elif key == "output_choices": - # DPI -> device value tables (range_choice handlers) - setting["output_choices"] = [[k, v] for k, v in value.items()] + # DPI -> device value tables (range_choice handlers). Identity tables + # (DPI sent as-is, e.g. 65000 entries for the Rival Pro) are only + # flagged, the app rebuilds them from input_range. + start, stop, step = info["input_range"] + if list(value.items()) == [(i, i) for i in range(start, stop + 1, step)]: + setting["output_choices_identity"] = True + else: + setting["output_choices"] = [[k, v] for k, v in value.items()] elif key == "buttons": setting["buttons"] = [{"name": k, **v} for k, v in value.items()] else: @@ -66,9 +91,15 @@ def export_profile(profile): "endpoint": profile["endpoint"], "settings": [export_setting(n, i) for n, i in profile["settings"].items()], } - for key in ("save_command", "firmware_version"): - if key in profile and profile[key]: - result[key] = profile[key] + if "save_command" in profile and profile["save_command"]: + result["save_command"] = profile["save_command"] + if "firmware_version" in profile and profile["firmware_version"]: + firmware = { + k: v for k, v in profile["firmware_version"].items() if k != "parse" + } + if "parse" in profile["firmware_version"]: + firmware["format"] = firmware_format(profile["firmware_version"]) + result["firmware_version"] = firmware if "battery_level" in profile: battery = { k: v