From 84511f0b3ebc5f87cc4a4bfff3a417afb3c30a18 Mon Sep 17 00:00:00 2001 From: Luis Urrutia Date: Sat, 4 Jul 2026 23:22:21 +0200 Subject: [PATCH 1/4] feat(hid): support LIGHTSPEED receivers and RGB effects Why: G502 X PLUS ships on a LIGHTSPEED receiver and reports RGB Effects 0x8071 plus Per Key Lighting V2 0x8081, so the existing Bolt/Unifying and older RGB paths could not surface or drive its lighting. Changes: - Recognize LIGHTSPEED receiver PIDs and enumerate them with the Unifying-style HID++ 1.0 receiver registers. - Try RGB Effects 0x8071 for solid colors before falling back to per-key lighting. --- crates/openlogi-cli/src/cmd/list.rs | 4 +- crates/openlogi-core/src/device.rs | 30 +++-- crates/openlogi-hid/src/inventory/probe.rs | 16 ++- crates/openlogi-hid/src/lib.rs | 2 +- crates/openlogi-hid/src/route.rs | 49 +++++-- crates/openlogi-hid/src/transport.rs | 1 + crates/openlogi-hid/src/transport/tests.rs | 8 ++ crates/openlogi-hid/src/write/lighting.rs | 127 ++++++++++++++++-- .../openlogi-hidpp/src/receiver/lightspeed.rs | 18 +++ crates/openlogi-hidpp/src/receiver/mod.rs | 22 +++ .../openlogi-hidpp/src/receiver/unifying.rs | 24 +++- 11 files changed, 259 insertions(+), 42 deletions(-) create mode 100644 crates/openlogi-hidpp/src/receiver/lightspeed.rs diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs index 4f648052..baac0cfe 100644 --- a/crates/openlogi-cli/src/cmd/list.rs +++ b/crates/openlogi-cli/src/cmd/list.rs @@ -20,8 +20,8 @@ pub async fn run(_args: ListArgs) -> Result<()> { permission: System Settings → Privacy & Security → Input Monitoring." ); println!( - " - hidpp 0.2 only recognises Logi Bolt receivers (PID 0xC548); other \ - receivers (Unifying) aren't surfaced yet." + " - LIGHTSPEED mice are normally already paired to their USB receiver; \ + Add Device is only for supported pairing flows." ); std::process::exit(2); } diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index 583257d1..d3877e03 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -77,10 +77,10 @@ pub struct Capabilities { pub buttons: bool, /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi). pub pointer: bool, - /// Solid-colour RGB the lighting panel can actually drive — HID++ - /// `ColorLedEffects` (`0x8070`) or `PerKeyLighting` (`0x8080`), the features - /// `set_keyboard_color` writes. Backlight-only families aren't driven by the - /// panel, so they don't flip this and don't earn an inert Lighting tab. + /// Solid-colour RGB the lighting panel can actually drive — HID++ RGB + /// effect engines (`0x8070` / `0x8071`) or per-zone lighting (`0x8080` / + /// `0x8081`). Backlight-only families aren't driven by the panel, so they + /// don't flip this and don't earn an inert Lighting tab. pub lighting: bool, /// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the /// firmware-reported `has_invert` capability. @@ -94,11 +94,9 @@ impl Capabilities { pub fn from_feature_ids(ids: &[u16]) -> Self { const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04]; const POINTER: [u16; 2] = [0x2201, 0x2202]; - // PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven - // by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a - // running onboard profile, falling back to 0x8080 per-key). Other families - // (backlight 0x198x) stay out so they don't earn a tab the panel can't drive. - const LIGHTING: [u16; 2] = [0x8080, 0x8070]; + // Include the modern G-series RGB families as well: G502 X PLUS reports + // 0x8071/0x8081 instead of the older 0x8070/0x8080 pair. + const LIGHTING: [u16; 4] = [0x8070, 0x8071, 0x8080, 0x8081]; let has = |family: &[u16]| ids.iter().any(|id| family.contains(id)); Self { buttons: has(&BUTTONS), @@ -256,7 +254,7 @@ pub struct DeviceInventory { #[cfg(test)] mod tests { - use super::DeviceKind; + use super::{Capabilities, DeviceKind}; #[test] fn registry_type_is_case_folded() { @@ -280,6 +278,18 @@ mod tests { assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown); } + #[test] + fn modern_rgb_effects_enable_lighting_capability() { + assert!( + Capabilities::from_feature_ids(&[0x8071]).lighting, + "G502 X PLUS exposes RGB effects instead of ColorLedEffects" + ); + assert!( + Capabilities::from_feature_ids(&[0x8081]).lighting, + "G502 X PLUS exposes per-key lighting v2 instead of v1" + ); + } + #[test] fn capabilities_track_the_driving_feature_ids() { use super::Capabilities; diff --git a/crates/openlogi-hid/src/inventory/probe.rs b/crates/openlogi-hid/src/inventory/probe.rs index e1d970ad..14db338c 100644 --- a/crates/openlogi-hid/src/inventory/probe.rs +++ b/crates/openlogi-hid/src/inventory/probe.rs @@ -56,7 +56,18 @@ pub(super) async fn probe_one( match receiver::detect(Arc::clone(&channel)) { Some(Receiver::Bolt(bolt)) => probe_bolt_receiver(channel, info, bolt, cache, tick).await, Some(Receiver::Unifying(unifying)) => { - probe_unifying_receiver(channel, info, unifying, cache, tick).await + probe_unifying_receiver(channel, info, unifying, "Unifying Receiver", cache, tick).await + } + Some(Receiver::Lightspeed(lightspeed)) => { + probe_unifying_receiver( + channel, + info, + lightspeed, + "Lightspeed Receiver", + cache, + tick, + ) + .await } None | Some(_) => { // No recognised receiver — this might be a directly-paired device @@ -130,6 +141,7 @@ async fn probe_unifying_receiver( channel: Arc, info: async_hid::DeviceInfo, unifying: UnifyingReceiver, + receiver_name: &str, cache: &HashMap, tick: u64, ) -> NodeProbe { @@ -199,7 +211,7 @@ async fn probe_unifying_receiver( NodeProbe { inventory: Some(DeviceInventory { receiver: ReceiverInfo { - name: "Unifying Receiver".to_string(), + name: receiver_name.to_string(), vendor_id: info.vendor_id, product_id: info.product_id, unique_id, diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index 6d0418d8..88a3670e 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -35,7 +35,7 @@ pub use pairing::{ Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver, PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair, }; -pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS}; +pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS}; pub use smartshift::{AUTO_DISENGAGE_PERMANENT, SmartShiftMode, SmartShiftStatus}; pub use write::{ DpiCapabilities, DpiInfo, FeatureEntry, HidppFeatureErrorKind, HidppOperation, LightingMethod, diff --git a/crates/openlogi-hid/src/route.rs b/crates/openlogi-hid/src/route.rs index 108fa2db..c5edda09 100644 --- a/crates/openlogi-hid/src/route.rs +++ b/crates/openlogi-hid/src/route.rs @@ -5,6 +5,8 @@ //! //! - [`DeviceRoute::Bolt`] — a device paired to a Logi Bolt receiver, reached //! through the receiver channel at a pairing slot. +//! - [`DeviceRoute::Unifying`] — a device paired to a receiver that uses the +//! Unifying-style HID++ 1.0 slot register layout, including LIGHTSPEED. //! - [`DeviceRoute::Direct`] — a device attached straight to the host over a //! USB cable or Bluetooth, reached on its own channel at the HID++ //! self-index [`DIRECT_DEVICE_INDEX`]. @@ -45,8 +47,8 @@ pub enum DeviceRoute { /// Pairing slot of the target device on that receiver. slot: u8, }, - /// Paired to a Logi Unifying receiver. Same addressing structure as Bolt - /// (receiver channel + pairing slot) but the receiver speaks HID++ 1.0. + /// Paired to a Logi Unifying-style receiver. Same addressing structure as + /// Bolt (receiver channel + pairing slot) but the receiver speaks HID++ 1.0. Unifying { /// Receiver unique ID used to select the physical Unifying receiver. receiver_uid: String, @@ -72,6 +74,12 @@ pub const BOLT_PIDS: &[u16] = &[0xc548]; /// need to construct the correct [`DeviceRoute`] variant from a raw inventory. pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532]; +/// USB product IDs that identify Logitech LIGHTSPEED gaming receivers. They +/// share Unifying-style HID++ 1.0 slot addressing, but not the same pairing UI. +pub const LIGHTSPEED_PIDS: &[u16] = &[ + 0xc539, 0xc53a, 0xc53d, 0xc53f, 0xc541, 0xc545, 0xc547, 0xc54d, +]; + impl DeviceRoute { /// The HID++ device index features are addressed at for this route: the /// pairing slot for a Bolt device, the self-index for a direct one. @@ -85,14 +93,11 @@ impl DeviceRoute { /// Build the route that reaches a paired device from a receiver inventory. /// - /// Picks [`DeviceRoute::Unifying`] or [`DeviceRoute::Bolt`] based on the - /// receiver's product ID using the canonical `UNIFYING_PIDS` / `BOLT_PIDS` - /// lists. Any receiver PID not in `UNIFYING_PIDS` — including future Bolt - /// variants whose PID isn't yet in `BOLT_PIDS` — defaults to - /// [`DeviceRoute::Bolt`] so writes keep working rather than silently - /// dropping. [`DeviceRoute::Direct`] is used for directly-attached devices - /// (slot == [`DIRECT_DEVICE_INDEX`] with no receiver UID). Returns `None` - /// when the receiver UID is unknown (writes are skipped, not mis-routed). + /// Picks the slot-addressed route based on the receiver's product ID using + /// the canonical `UNIFYING_PIDS`, `LIGHTSPEED_PIDS`, and `BOLT_PIDS` lists. + /// LIGHTSPEED currently reuses [`DeviceRoute::Unifying`] because the + /// serialized route shape is the same and adding another variant would be a + /// wire-protocol break for no routing benefit. #[must_use] pub fn device_route_for(inv: &DeviceInventory, slot: u8) -> Option { match &inv.receiver.unique_id { @@ -100,6 +105,12 @@ impl DeviceRoute { receiver_uid: uid.clone(), slot, }), + Some(uid) if LIGHTSPEED_PIDS.contains(&inv.receiver.product_id) => { + Some(Self::Unifying { + receiver_uid: uid.clone(), + slot, + }) + } Some(uid) => { // Default to Bolt for any receiver whose PID is not in // UNIFYING_PIDS. This covers both known Bolt PIDs (BOLT_PIDS) @@ -178,11 +189,12 @@ pub(crate) async fn open_route_channel( } } DeviceRoute::Unifying { receiver_uid, .. } => { - let Some(Receiver::Unifying(unifying)) = receiver::detect(Arc::clone(&channel)) + let Some(receiver @ (Receiver::Unifying(_) | Receiver::Lightspeed(_))) = + receiver::detect(Arc::clone(&channel)) else { continue; }; - if let Ok(uid) = unifying.get_unique_id().await + if let Ok(uid) = receiver.get_unique_id().await && uid.eq_ignore_ascii_case(receiver_uid) { return Ok(Some(channel)); @@ -198,7 +210,7 @@ pub(crate) async fn open_route_channel( mod tests { use openlogi_core::device::{DeviceInventory, ReceiverInfo}; - use super::{DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS}; + use super::{DIRECT_DEVICE_INDEX, DeviceRoute, LIGHTSPEED_PIDS, UNIFYING_PIDS}; fn inv(product_id: u16, unique_id: Option<&str>) -> DeviceInventory { DeviceInventory { @@ -223,6 +235,17 @@ mod tests { } } + #[test] + fn device_route_for_lightspeed_pids_create_slot_route() { + for &pid in LIGHTSPEED_PIDS { + let route = DeviceRoute::device_route_for(&inv(pid, Some("LS")), 1); + assert!( + matches!(route, Some(DeviceRoute::Unifying { ref receiver_uid, slot: 1 }) if receiver_uid == "LS"), + "pid {pid:#06x} should produce a slot-addressed route" + ); + } + } + #[test] fn device_route_for_bolt_pid_creates_bolt_route() { // 0xC548 is Bolt; anything not in UNIFYING_PIDS defaults to Bolt so diff --git a/crates/openlogi-hid/src/transport.rs b/crates/openlogi-hid/src/transport.rs index 2e8e0e0a..1c6e3aa9 100644 --- a/crates/openlogi-hid/src/transport.rs +++ b/crates/openlogi-hid/src/transport.rs @@ -173,6 +173,7 @@ fn is_receiver_child_sysfs_path(path: &str) -> bool { crate::BOLT_PIDS .iter() .chain(crate::UNIFYING_PIDS.iter()) + .chain(crate::LIGHTSPEED_PIDS.iter()) .any(|&pid| { let marker = format!(":{LOGITECH_VID:04X}:{pid:04X}."); // A parent component contains the marker followed by at least one diff --git a/crates/openlogi-hid/src/transport/tests.rs b/crates/openlogi-hid/src/transport/tests.rs index b67e75e5..5240e2b2 100644 --- a/crates/openlogi-hid/src/transport/tests.rs +++ b/crates/openlogi-hid/src/transport/tests.rs @@ -77,6 +77,9 @@ const UNIFYING_RECEIVER: &str = "/sys/devices/pci0000:00/0000:00:14.0/usb3/3-5/3 // Sysfs path: child of Bolt receiver const BOLT_CHILD: &str = "/sys/devices/pci0000:00/0000:00:14.0/usb3/3-5/\ 0003:046D:C548.0001/0003:046D:B037.0002"; +// Sysfs path: child of Lightspeed receiver +const LIGHTSPEED_CHILD: &str = "/sys/devices/pci0000:00/0000:00:14.0/usb3/3-5/\ + 0003:046D:C547.0001/0003:046D:4099.0002"; // Sysfs path: unrelated non-Logitech device const UNRELATED: &str = "/sys/devices/pci0000:00/0000:00:15.0/i2c-0/0018:06CB:CE67.0001"; @@ -95,6 +98,11 @@ fn child_of_bolt_receiver_is_detected() { assert!(is_receiver_child_sysfs_path(BOLT_CHILD)); } +#[test] +fn child_of_lightspeed_receiver_is_detected() { + assert!(is_receiver_child_sysfs_path(LIGHTSPEED_CHILD)); +} + #[test] fn unrelated_device_is_not_a_child() { assert!(!is_receiver_child_sysfs_path(UNRELATED)); diff --git a/crates/openlogi-hid/src/write/lighting.rs b/crates/openlogi-hid/src/write/lighting.rs index 41e7bad4..1bfa94a0 100644 --- a/crates/openlogi-hid/src/write/lighting.rs +++ b/crates/openlogi-hid/src/write/lighting.rs @@ -6,6 +6,10 @@ use hidpp::{ feature::{ CreatableFeature, color_led_effects::{ColorLedEffectsFeature, Persistence, ZONE_EFFECT_PARAM_COUNT}, + rgb_effects::{ + CLUSTER_EFFECT_PARAM_COUNT, EventsNotificationFlags, PowerModeTarget, + RgbEffectsFeature, RgbPersistence, SwControlFlags, + }, }, }; use tracing::debug; @@ -22,6 +26,9 @@ const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080; /// (`0x8080`) write can't override on G-series keyboards (the firmware keeps /// replaying its stored effect). Preferred for a solid colour for that reason. const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070; +/// HID++ `RgbEffects` (`0x8071`) — modern per-cluster RGB effect engine used +/// by newer G-series devices such as the G502 X PLUS. +const RGB_EFFECTS_FEATURE: u16 = 0x8071; // HID++ 2.0 report ids: 0x12 is the 64-byte "very long" report that streams a // batch of (keyID, R, G, B) entries; 0x11 is the 20-byte "long" report used both @@ -44,6 +51,9 @@ const KEYS_PER_FRAME: u8 = 0x0e; // agent re-applying the saved colour on device arrival (orchestrator reapply), // avoiding flash wear on every colour pick. const EFFECT_FIXED: u8 = 0x01; +// 0x8071's effect id for a static RGB colour. The per-cluster *index* is +// discovered at runtime because firmware can order supported effects freely. +const RGB_EFFECT_STATIC: u16 = 0x0001; // The old raw `0x8070` path intentionally wrote only zones 0..4: enough for the // keyboards this path targets and bounded by a small, predictable delay budget. // Keep that cap even though the typed wrapper can query the reported zone count; @@ -93,15 +103,28 @@ pub async fn set_keyboard_color_with( match method { LightingMethod::PerKey => set_color_per_key(route, r, g, b).await, LightingMethod::Effects => set_color_effects(route, r, g, b).await, - LightingMethod::Auto => match set_color_effects(route, r, g, b).await { - Err(WriteError::FeatureUnsupported { feature_hex }) - if feature_hex == COLOR_LED_EFFECTS_FEATURE => - { - debug!("no 0x8070 effect engine — falling back to 0x8080 per-key"); - set_color_per_key(route, r, g, b).await - } - other => other, - }, + LightingMethod::Auto => set_color_auto(route, r, g, b).await, + } +} + +async fn set_color_auto(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> { + match set_color_effects(route, r, g, b).await { + Err(WriteError::FeatureUnsupported { feature_hex }) + if feature_hex == COLOR_LED_EFFECTS_FEATURE => + { + debug!("no 0x8070 effect engine — trying 0x8071 RGB effects"); + } + other => return other, + } + + match set_color_rgb_effects(route, r, g, b).await { + Err(WriteError::FeatureUnsupported { feature_hex }) + if feature_hex == RGB_EFFECTS_FEATURE => + { + debug!("no 0x8071 effect engine — falling back to 0x8080 per-key"); + set_color_per_key(route, r, g, b).await + } + other => other, } } @@ -192,6 +215,92 @@ fn classify_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteEr classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID) } +/// Set a solid colour via `RgbEffects` (`0x8071`): take software control, find +/// each cluster's advertised static-colour effect, and apply it in RAM. +async fn set_color_rgb_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> { + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(std::sync::Arc::clone(&channel), index) + .await + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; + feature + .set_sw_control( + SwControlFlags::ALL_CLUSTERS, + EventsNotificationFlags::empty(), + ) + .await + .map_err(classify_rgb_lighting_error)?; + + let cluster_count = feature + .get_device_info() + .await + .map_err(classify_rgb_lighting_error)? + .cluster_count; + let mut params = [0u8; CLUSTER_EFFECT_PARAM_COUNT]; + params[0] = r; + params[1] = g; + params[2] = b; + + let mut written = 0u8; + for cluster in 0..cluster_count { + let Some(effect_index) = static_rgb_effect_index(&feature, cluster).await? else { + debug!(index, cluster, "0x8071 cluster has no static RGB effect"); + continue; + }; + feature + .set_rgb_cluster_effect( + cluster, + effect_index, + params, + RgbPersistence::VOLATILE, + PowerModeTarget::FullPower, + ) + .await + .map_err(classify_rgb_lighting_error)?; + written = written.saturating_add(1); + tokio::time::sleep(FRAME_GAP).await; + } + if written == 0 { + return Err(WriteError::UnsupportedResponse { + operation: HidppOperation::Lighting, + feature_hex: RGB_EFFECTS_FEATURE, + }); + } + debug!( + index, + cluster_count, written, r, g, b, "set colour via 0x8071 RGB effects" + ); + Ok(()) + }) + .await +} + +async fn static_rgb_effect_index( + feature: &RgbEffectsFeature, + cluster: u8, +) -> Result, WriteError> { + let effects_number = feature + .get_cluster_info(cluster) + .await + .map_err(classify_rgb_lighting_error)? + .effects_number; + for effect_index in 0..effects_number { + let info = feature + .get_effect_info(cluster, effect_index) + .await + .map_err(classify_rgb_lighting_error)?; + if info.effect_id == RGB_EFFECT_STATIC { + return Ok(Some(effect_index)); + } + } + Ok(None) +} + +fn classify_rgb_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError { + classify_hidpp_error(error, HidppOperation::Lighting, RgbEffectsFeature::ID) +} + /// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour /// in 64-byte `0x12` frames, then commit. `FeatureUnsupported` when the device /// exposes no `0x8080`. diff --git a/crates/openlogi-hidpp/src/receiver/lightspeed.rs b/crates/openlogi-hidpp/src/receiver/lightspeed.rs new file mode 100644 index 00000000..0a79cb49 --- /dev/null +++ b/crates/openlogi-hidpp/src/receiver/lightspeed.rs @@ -0,0 +1,18 @@ +//! Logitech LIGHTSPEED gaming receivers. +//! +//! LIGHTSPEED receivers expose the same HID++ 1.0 receiver registers used by +//! Unifying-style enumeration, but have their own USB product IDs and are not +//! Logi Bolt receivers. + +/// All USB vendor & product ID pairs that are known to identify LIGHTSPEED +/// gaming receivers. +pub const VPID_PAIRS: &[(u16, u16)] = &[ + (0x046d, 0xc539), + (0x046d, 0xc53a), + (0x046d, 0xc53d), + (0x046d, 0xc53f), + (0x046d, 0xc541), + (0x046d, 0xc545), + (0x046d, 0xc547), + (0x046d, 0xc54d), +]; diff --git a/crates/openlogi-hidpp/src/receiver/mod.rs b/crates/openlogi-hidpp/src/receiver/mod.rs index 00486f6b..25fec021 100755 --- a/crates/openlogi-hidpp/src/receiver/mod.rs +++ b/crates/openlogi-hidpp/src/receiver/mod.rs @@ -19,6 +19,7 @@ use thiserror::Error; use crate::{channel::HidppChannel, protocol::v10::Hidpp10Error}; pub mod bolt; +pub mod lightspeed; pub mod unifying; /// The index to use when communicating with the receiver on any HID++ channel. @@ -35,6 +36,12 @@ pub fn detect(chan: Arc) -> Option { if unifying::VPID_PAIRS.contains(vpid_pair) { return unifying::Receiver::new(chan).ok().map(Receiver::Unifying); } + + if lightspeed::VPID_PAIRS.contains(vpid_pair) { + return unifying::Receiver::new_lightspeed(chan) + .ok() + .map(Receiver::Lightspeed); + } None } @@ -46,6 +53,9 @@ pub enum Receiver { Bolt(bolt::Receiver), /// Logitech Unifying receiver. Unifying(unifying::Receiver), + /// Logitech LIGHTSPEED gaming receiver. These use the same HID++ 1.0 + /// receiver registers OpenLogi already uses for Unifying-style devices. + Lightspeed(unifying::Receiver), } impl Receiver { @@ -54,6 +64,7 @@ impl Receiver { match self { Self::Bolt(_) => "Logi Bolt Receiver", Self::Unifying(_) => "Unifying Receiver", + Self::Lightspeed(_) => "Lightspeed Receiver", } .to_string() } @@ -66,10 +77,21 @@ impl Receiver { match self { Self::Bolt(bolt) => bolt.get_unique_id().await, Self::Unifying(unifying) => unifying.get_unique_id().await, + Self::Lightspeed(lightspeed) => lightspeed.get_unique_id().await, } } } +#[cfg(test)] +mod tests { + use super::lightspeed; + + #[test] + fn lightspeed_receiver_list_contains_g502_x_plus_receiver() { + assert!(lightspeed::VPID_PAIRS.contains(&(0x046d, 0xc547))); + } +} + /// Represents an error returned by a receiver. #[derive(Debug, Error)] #[non_exhaustive] diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs index 42611a7e..54855089 100755 --- a/crates/openlogi-hidpp/src/receiver/unifying.rs +++ b/crates/openlogi-hidpp/src/receiver/unifying.rs @@ -69,15 +69,29 @@ pub struct Receiver { } impl Receiver { + fn new_for_pairs(chan: Arc, pairs: &[(u16, u16)]) -> Result { + if !pairs.contains(&(chan.vendor_id, chan.product_id)) { + return Err(ReceiverError::UnknownReceiver); + } + + Ok(Self::new_unchecked(chan)) + } + /// Tries to initialize a new [`Receiver`] from a raw HID++ channel. /// /// Returns [`ReceiverError::UnknownReceiver`] when the channel's VID/PID /// doesn't match any known Unifying receiver. pub fn new(chan: Arc) -> Result { - if !VPID_PAIRS.contains(&(chan.vendor_id, chan.product_id)) { - return Err(ReceiverError::UnknownReceiver); - } + Self::new_for_pairs(chan, VPID_PAIRS) + } + + /// Initializes a receiver that shares the Unifying-style HID++ receiver + /// register layout but is branded as LIGHTSPEED. + pub(crate) fn new_lightspeed(chan: Arc) -> Result { + Self::new_for_pairs(chan, super::lightspeed::VPID_PAIRS) + } + fn new_unchecked(chan: Arc) -> Self { let emitter = Arc::new(EventEmitter::new()); let listener = chan.add_msg_listener_guarded({ @@ -111,11 +125,11 @@ impl Receiver { } }); - Ok(Receiver { + Receiver { _listener: Arc::new(listener), chan, emitter, - }) + } } /// Creates a new listener for receiving receiver events. From ab10d47d63440aef6ea2fd8876665ef52b25df0f Mon Sep 17 00:00:00 2001 From: Luis Urrutia Date: Sun, 5 Jul 2026 07:48:00 +0200 Subject: [PATCH 2/4] fix(cli): test lighting on receiver devices --- crates/openlogi-cli/src/cmd/diag/lighting.rs | 60 ++++++-------------- 1 file changed, 16 insertions(+), 44 deletions(-) diff --git a/crates/openlogi-cli/src/cmd/diag/lighting.rs b/crates/openlogi-cli/src/cmd/diag/lighting.rs index 3ae708e8..7586213c 100644 --- a/crates/openlogi-cli/src/cmd/diag/lighting.rs +++ b/crates/openlogi-cli/src/cmd/diag/lighting.rs @@ -1,16 +1,17 @@ -//! `openlogi diag lighting ` — set a wired RGB keyboard to a solid -//! colour via HID++ `PerKeyLighting` (0x8080). -//! -//! Targets the first online direct-attached (USB) Logitech device — i.e. a -//! wired G-series keyboard — by VID/PID, so it isn't tied to one model. +//! `openlogi diag lighting ` — set an online RGB device to a solid +//! colour via the same HID++ lighting write path the GUI uses. use anyhow::{Result, anyhow}; use clap::{Args, ValueEnum}; -use openlogi_hid::{DeviceRoute, LightingMethod}; +use openlogi_hid::LightingMethod; + +use super::select_device; + +const LIGHTING_FEATURES: &[u16] = &[0x8070, 0x8071, 0x8080, 0x8081]; #[derive(Debug, Clone, Copy, ValueEnum)] pub enum Method { - /// Prefer 0x8070 ColorLedEffects, fall back to 0x8080 per-key (default). + /// Prefer effect engines, then fall back to 0x8080 per-key (default). Auto, /// Force 0x8070 ColorLedEffects (the fixed-effect onboard override). Effects, @@ -33,8 +34,8 @@ pub struct LightingArgs { /// Colour as `RRGGBB` hex (e.g. `ff0000` for red). pub color: String, - /// Run against the wired device whose name contains this string - /// (case-insensitive). Useful when several keyboards are connected. + /// Run against the online device whose name contains this string + /// (case-insensitive). Useful when several devices are connected. #[arg(long, value_name = "NAME")] pub device: Option, @@ -54,41 +55,12 @@ pub async fn run(args: LightingArgs) -> Result<()> { let g = ((rgb >> 8) & 0xff) as u8; let b = (rgb & 0xff) as u8; - let device_query = args.device; - let needle = device_query.as_deref().map(str::to_lowercase); - - let inventories = openlogi_hid::enumerate().await?; - let (route, name) = inventories - .iter() - .find_map(|inv| { - // Direct (USB-wired) devices carry no receiver UID — that's the - // wired keyboard. Bolt/Unifying receivers (mice) are skipped. - if inv.receiver.unique_id.is_some() { - return None; - } - let paired = inv.paired.iter().find(|p| p.online)?; - let name = paired.codename.clone().unwrap_or_else(|| { - format!( - "{:04x}:{:04x}", - inv.receiver.vendor_id, inv.receiver.product_id - ) - }); - if let Some(ref n) = needle - && !name.to_lowercase().contains(n.as_str()) - { - return None; - } - let route = DeviceRoute::Direct { - vendor_id: inv.receiver.vendor_id, - product_id: inv.receiver.product_id, - }; - Some((route, name)) - }) - .ok_or_else(|| match &device_query { - Some(q) => anyhow!("no wired device matches `--device {q}`"), - None => { - anyhow!("no wired (direct-USB) Logitech device found — is the keyboard plugged in?") - } + let device_query = args.device.as_deref(); + let (route, name) = select_device(device_query, LIGHTING_FEATURES) + .await + .map_err(|e| match device_query { + Some(q) => anyhow!("no lighting-capable online device matches `--device {q}`: {e}"), + None => anyhow!("no lighting-capable online device found: {e}"), })?; let method: LightingMethod = args.method.into(); From ce82abb5b5047aec61050955193294d113051f2e Mon Sep 17 00:00:00 2001 From: Luis Urrutia Date: Sun, 5 Jul 2026 07:49:29 +0200 Subject: [PATCH 3/4] docs(cli): update lighting diag help text --- crates/openlogi-cli/src/cmd/diag/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openlogi-cli/src/cmd/diag/mod.rs b/crates/openlogi-cli/src/cmd/diag/mod.rs index 25ac24d0..41833eff 100644 --- a/crates/openlogi-cli/src/cmd/diag/mod.rs +++ b/crates/openlogi-cli/src/cmd/diag/mod.rs @@ -26,7 +26,7 @@ pub enum DiagCmd { Dpi(dpi::DpiArgs), /// Read SmartShift mode → toggle → read back → toggle back → report. Smartshift(smartshift::SmartshiftArgs), - /// Set a wired RGB keyboard to a solid colour (e.g. `ff0000` for red). + /// Set an online RGB device to a solid colour (e.g. `ff0000` for red). Lighting(lighting::LightingArgs), } From 6f038d9862129ba37430d663eb34c05b4e9b3d5d Mon Sep 17 00:00:00 2001 From: Luis Urrutia Date: Sun, 5 Jul 2026 15:07:18 +0200 Subject: [PATCH 4/4] feat(g502): add G502 family model support Why: G502 X Plus devices paired through LIGHTSPEED can expose RGB and host-hook mouse buttons without the MX-line ReprogControls table, so they need model-specific identity and UI handling. Changes: - detect G502-family model IDs and names, including the live G502 X Plus IDs - add a two-perspective G502 button layout with read-only native controls - keep display-only gaming controls out of runtime binding projection until their protocol is implemented - document follow-up protocol and diagnostics work in IMPROVEMENTS.md --- IMPROVEMENTS.md | 6 + crates/openlogi-agent-core/src/bindings.rs | 22 +- crates/openlogi-core/src/binding.rs | 67 ++- crates/openlogi-core/src/device.rs | 89 +++- crates/openlogi-gui/locales/da.yml | 9 + crates/openlogi-gui/locales/de.yml | 9 + crates/openlogi-gui/locales/el.yml | 9 + crates/openlogi-gui/locales/en.yml | 9 + crates/openlogi-gui/locales/es.yml | 9 + crates/openlogi-gui/locales/fi.yml | 9 + crates/openlogi-gui/locales/fr.yml | 9 + crates/openlogi-gui/locales/it.yml | 9 + crates/openlogi-gui/locales/ja.yml | 9 + crates/openlogi-gui/locales/ko.yml | 9 + crates/openlogi-gui/locales/nb.yml | 9 + crates/openlogi-gui/locales/nl.yml | 9 + crates/openlogi-gui/locales/pl.yml | 9 + crates/openlogi-gui/locales/pt-BR.yml | 9 + crates/openlogi-gui/locales/pt-PT.yml | 9 + crates/openlogi-gui/locales/ru.yml | 9 + crates/openlogi-gui/locales/sv.yml | 9 + crates/openlogi-gui/locales/zh-CN.yml | 9 + crates/openlogi-gui/locales/zh-HK.yml | 9 + crates/openlogi-gui/locales/zh-TW.yml | 9 + crates/openlogi-gui/src/app.rs | 35 +- .../src/mouse_model/button_layout.rs | 354 +++++++++++++ .../openlogi-gui/src/mouse_model/geometry.rs | 119 +++-- crates/openlogi-gui/src/mouse_model/mod.rs | 1 + crates/openlogi-gui/src/mouse_model/view.rs | 476 ++++++++++++++---- crates/openlogi-hid/src/inventory/features.rs | 68 ++- crates/openlogi-hid/src/inventory/probe.rs | 12 +- 31 files changed, 1248 insertions(+), 181 deletions(-) create mode 100644 IMPROVEMENTS.md create mode 100644 crates/openlogi-gui/src/mouse_model/button_layout.rs diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 00000000..287efe41 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,6 @@ +# Improvements + +- Add a `--device` selector to `openlogi diag features`; `diag controls` already has one, but feature dumps cannot currently isolate one paired device when several are connected. +- Implement the G502 onboard/gaming-button protocol before promoting the currently read-only DPI Up/Down, DPI Shift, Smart Shift, wheel tilt, profile cycle, G-Shift, or other non-standard controls to editable runtime-dispatched buttons. +- Add a cross-platform hook-event diagnostic that prints raw mouse button numbers/codes so G502 extra buttons can be verified on macOS, Linux, and Windows without guessing. +- Extend the asset pipeline/index for G502-family renders and hotspot metadata if the current OpenLogi asset cache does not include those depots. diff --git a/crates/openlogi-agent-core/src/bindings.rs b/crates/openlogi-agent-core/src/bindings.rs index a416709c..a1b93679 100644 --- a/crates/openlogi-agent-core/src/bindings.rs +++ b/crates/openlogi-agent-core/src/bindings.rs @@ -12,7 +12,7 @@ use openlogi_core::binding::{ use openlogi_core::config::Config; /// Effective per-button single-action map for the device `config_key`, with -/// `app_bundle`'s per-app overlay applied. Unset buttons fall back to +/// `app_bundle`'s per-app overlay applied. Unset bindable buttons fall back to /// [`default_binding`]. /// /// This is the map the OS hook and the HID++ button-press path consume, so a @@ -28,12 +28,15 @@ pub fn bindings_for( let stored = config_key .map(|key| config.effective_bindings(key, app_bundle)) .unwrap_or_default(); - let mut bindings: BTreeMap = ButtonId::ALL + let mut bindings: BTreeMap = ButtonId::BINDABLE .iter() .copied() .map(|b| (b, default_binding(b))) .collect(); for (k, binding) in stored { + if !k.is_bindable() { + continue; + } // A gesture binding with no explicit `Click` has no opinion on the // plain-press action, so leave the button's default seed in place rather // than clobbering it with the `Action::None` that `click_action()` would @@ -160,6 +163,21 @@ mod tests { ); } + #[test] + fn display_only_controls_are_not_seeded_or_projected() { + let mut cfg = Config::default(); + cfg.set_binding( + "g502", + ButtonId::DpiUp, + Binding::Single(Action::SetDpiPreset(2)), + ); + + let projected = bindings_for(&cfg, Some("g502"), None); + + assert!(!projected.contains_key(&ButtonId::DpiUp)); + assert!(!projected.contains_key(&ButtonId::SmartShift)); + } + #[test] fn oshook_gestures_collects_only_os_hook_gesture_buttons() { let mut cfg = Config::default(); diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 1463492a..686a6bde 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -12,9 +12,12 @@ use std::time::Instant; use serde::{Deserialize, Serialize}; -/// One of the user-rebindable hotspots on a Logi mouse. The order matches the -/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the -/// default-binding generator and the popover trigger list. +/// One logical control on a Logi mouse. The order matches the physical layout +/// from front to side. +/// +/// Not every ID is currently runtime-bindable: some gaming-mouse controls are +/// display-only until their HID++ gaming-button protocol is implemented. Use +/// [`ButtonId::BINDABLE`] when seeding or dispatching user bindings. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum ButtonId { LeftClick, @@ -25,7 +28,19 @@ pub enum ButtonId { /// The "ModeShift" button under the wheel — typically used for SmartShift / /// DPI cycle. Named `DpiToggle` for historical reasons. DpiToggle, - /// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its + /// Increase DPI on gaming mice that expose separate DPI buttons. + DpiUp, + /// Decrease DPI on gaming mice that expose separate DPI buttons. + DpiDown, + /// Temporary DPI-shift / sniper button on gaming mice. + DpiShift, + /// Scroll-wheel tilt left on mice that report wheel tilt as a button. + WheelLeft, + /// Scroll-wheel tilt right on mice that report wheel tilt as a button. + WheelRight, + /// Hardware wheel mode button on gaming mice with ratchet/free-spin wheels. + SmartShift, + /// The horizontal thumb wheel's click. Kept in [`ButtonId::BINDABLE`] so its /// default still seeds and dispatches when the wheel is diverted, even /// though the mouse model surfaces the two rotation directions instead of /// the click (see `mouse_model::geometry`). @@ -41,7 +56,29 @@ pub enum ButtonId { } impl ButtonId { - pub const ALL: [ButtonId; 10] = [ + /// Every known serializable button/control ID, including display-only + /// controls surfaced by device-family-specific layouts. + pub const ALL: [ButtonId; 16] = [ + ButtonId::LeftClick, + ButtonId::RightClick, + ButtonId::MiddleClick, + ButtonId::Back, + ButtonId::Forward, + ButtonId::DpiToggle, + ButtonId::DpiUp, + ButtonId::DpiDown, + ButtonId::DpiShift, + ButtonId::WheelLeft, + ButtonId::WheelRight, + ButtonId::SmartShift, + ButtonId::Thumbwheel, + ButtonId::ThumbwheelScrollUp, + ButtonId::ThumbwheelScrollDown, + ButtonId::GestureButton, + ]; + + /// Controls whose saved bindings can currently be edited and dispatched. + pub const BINDABLE: [ButtonId; 10] = [ ButtonId::LeftClick, ButtonId::RightClick, ButtonId::MiddleClick, @@ -54,6 +91,11 @@ impl ButtonId { ButtonId::GestureButton, ]; + #[must_use] + pub fn is_bindable(self) -> bool { + Self::BINDABLE.contains(&self) + } + /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev) /// remaps: Middle, Back, or Forward. The primary L/R clicks always pass /// through (suppressing them would brick the mouse), and the DPI / thumb / @@ -79,6 +121,12 @@ impl ButtonId { ButtonId::Back => "Back", ButtonId::Forward => "Forward", ButtonId::DpiToggle => "DPI Toggle", + ButtonId::DpiUp => "DPI Up", + ButtonId::DpiDown => "DPI Down", + ButtonId::DpiShift => "DPI Shift", + ButtonId::WheelLeft => "Wheel Left", + ButtonId::WheelRight => "Wheel Right", + ButtonId::SmartShift => "Smart Shift", ButtonId::Thumbwheel => "Thumb Wheel", ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up", ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down", @@ -862,15 +910,18 @@ pub fn default_binding(button: ButtonId) -> Action { ButtonId::MiddleClick => Action::MiddleClick, ButtonId::Back => Action::BrowserBack, ButtonId::Forward => Action::BrowserForward, - ButtonId::DpiToggle => Action::CycleDpiPresets, + ButtonId::DpiToggle | ButtonId::DpiUp | ButtonId::DpiDown | ButtonId::DpiShift => { + Action::CycleDpiPresets + } + ButtonId::WheelLeft | ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft, + ButtonId::WheelRight | ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight, + ButtonId::SmartShift => Action::ToggleSmartShift, ButtonId::Thumbwheel => Action::AppExpose, // The thumb wheel scrolls horizontally by default: rotating it produces // continuous horizontal scroll, with "up" → right and "down" → left. // The wheel watcher renders these two actions as smooth, sensitivity- // scaled scrolling rather than the discrete per-press burst a button // would get (see `watchers::gesture`). - ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight, - ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft, ButtonId::GestureButton => Action::MissionControl, } } diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index d3877e03..de0a50cc 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -73,7 +73,9 @@ impl DeviceKind { reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit" )] pub struct Capabilities { - /// Reprogrammable buttons — HID++ `0x1b00`–`0x1b04` (ReprogControls). + /// Button bindings the UI/agent can offer. Most devices advertise this via + /// HID++ `0x1b00`–`0x1b04` (ReprogControls); known gaming mice can also be + /// host-hook bindable without exposing that MX-line feature. pub buttons: bool, /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi). pub pointer: bool, @@ -106,6 +108,19 @@ impl Capabilities { } } + /// Add measured-by-model capabilities that do not appear in the HID++ + /// feature table. G502-family mice do not expose MX `0x1b04` controls, but + /// their standard mouse buttons are still host-hook bindable. + pub fn include_known_model_support( + &mut self, + model: Option<&DeviceModelInfo>, + name: Option<&str>, + ) { + if is_g502_family(model, name) { + self.buttons = true; + } + } + /// Best-effort capabilities for a device we could not probe (offline / /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback /// when no measured [`Capabilities`] exist — a sleeping mouse should still @@ -129,6 +144,36 @@ impl Capabilities { } } +/// Product/model IDs observed for the G502 family across HID++ model-info, +/// wired USB, and LIGHTSPEED variants. The LIGHTSPEED receiver PID itself is +/// deliberately excluded: a receiver can host non-G502 devices. +const G502_FAMILY_MODEL_IDS: &[u16] = &[ + 0x407e, 0x407f, // G502 LIGHTSPEED wireless PID. + 0x4099, // G502 X PLUS wireless PID (live HID++ model id). + 0x409a, 0xc08b, // G502 HERO USB PID. + 0xc090, 0xc091, 0xc095, // G502 X PLUS secondary live HID++ model id. + 0xc098, 0xc09d, 0xc332, +]; + +/// Whether the runtime identity belongs to the Logitech G502 family. +#[must_use] +pub fn is_g502_family(model: Option<&DeviceModelInfo>, name: Option<&str>) -> bool { + let model_match = model.is_some_and(|info| { + info.model_ids + .iter() + .any(|id| *id != 0 && G502_FAMILY_MODEL_IDS.contains(id)) + }); + model_match || name.is_some_and(name_mentions_g502) +} + +fn name_mentions_g502(name: &str) -> bool { + name.to_ascii_uppercase() + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::() + .contains("G502") +} + /// Coarse battery bucket reported by the device firmware. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -254,7 +299,22 @@ pub struct DeviceInventory { #[cfg(test)] mod tests { - use super::{Capabilities, DeviceKind}; + use super::{Capabilities, DeviceKind, DeviceModelInfo, DeviceTransports, is_g502_family}; + + fn g502_x_plus_model() -> DeviceModelInfo { + DeviceModelInfo { + entity_count: 1, + serial_number: None, + unit_id: [0x66, 0x2e, 0xa5, 0xf1], + transports: DeviceTransports { + usb: true, + equad: true, + ..Default::default() + }, + model_ids: [0x4099, 0xc095, 0], + extended_model_id: 0, + } + } #[test] fn registry_type_is_case_folded() { @@ -323,6 +383,31 @@ mod tests { ); } + #[test] + fn g502_family_matches_live_model_ids() { + assert!(is_g502_family(Some(&g502_x_plus_model()), None)); + } + + #[test] + fn g502_family_matches_marketing_name() { + assert!(is_g502_family(None, Some("Logitech G502 X Plus"))); + assert!(!is_g502_family(None, Some("MX Master 3S"))); + } + + #[test] + fn g502_model_support_exposes_host_button_bindings() { + let mut caps = Capabilities::from_feature_ids(&[0x0001, 0x8071]); + assert!( + !caps.buttons, + "the G502 X Plus does not expose MX ReprogControls" + ); + + caps.include_known_model_support(Some(&g502_x_plus_model()), Some("G502 X PLUS")); + + assert!(caps.buttons); + assert!(caps.lighting); + } + #[test] fn presumed_capabilities_keep_an_unprobed_mouse_configurable() { use super::Capabilities; diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 2e2f041f..7e5dafde 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Tildel %{name}" "Unbound": "Ikke tildelt" "Default": "Standard" +"Native": "Indbygget" "5 directions": "5 retninger" "Middle": "Midterknap" "DPI Preset %{index}": "DPI-forindstilling %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Tilbage" "Forward": "Frem" "DPI Toggle": "DPI-skift" +"DPI Up": "DPI op" +"DPI Down": "DPI ned" +"DPI Shift": "DPI-skift" +"Wheel Left": "Hjul venstre" +"Wheel Right": "Hjul højre" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Tommelfingerhjul" "Gesture Button": "Bevægelsesknap" +"View 1": "Visning 1" +"View 2": "Visning 2" "Up": "Op" "Down": "Ned" "Left": "Venstre" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 99480ebe..d150c85d 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} zuweisen" "Unbound": "Nicht zugewiesen" "Default": "Standard" +"Native": "Nativ" "5 directions": "5 Richtungen" "Middle": "Mitteltaste" "DPI Preset %{index}": "DPI-Voreinstellung %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Zurück" "Forward": "Vor" "DPI Toggle": "DPI-Umschaltung" +"DPI Up": "DPI erhöhen" +"DPI Down": "DPI verringern" +"DPI Shift": "DPI Shift" +"Wheel Left": "Rad links" +"Wheel Right": "Rad rechts" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Daumenrad" "Gesture Button": "Gestentaste" +"View 1": "Ansicht 1" +"View 2": "Ansicht 2" "Up": "Oben" "Down": "Unten" "Left": "Links" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index 3b96ae1c..c4d43987 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Αντιστοίχιση %{name}" "Unbound": "Χωρίς αντιστοίχιση" "Default": "Προεπιλογή" +"Native": "Εγγενές" "5 directions": "5 κατευθύνσεις" "Middle": "Μεσαίο" "DPI Preset %{index}": "Προεπιλογή DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Πίσω" "Forward": "Εμπρός" "DPI Toggle": "Εναλλαγή DPI" +"DPI Up": "Αύξηση DPI" +"DPI Down": "Μείωση DPI" +"DPI Shift": "Μετατόπιση DPI" +"Wheel Left": "Τροχός αριστερά" +"Wheel Right": "Τροχός δεξιά" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Πλαϊνός τροχός" "Gesture Button": "Κουμπί χειρονομιών" +"View 1": "Προβολή 1" +"View 2": "Προβολή 2" "Up": "Πάνω" "Down": "Κάτω" "Left": "Αριστερά" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 7b567aa7..a03ff933 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Bind %{name}" "Unbound": "Unbound" "Default": "Default" +"Native": "Native" "5 directions": "5 directions" "Middle": "Middle" "DPI Preset %{index}": "DPI Preset %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Back" "Forward": "Forward" "DPI Toggle": "DPI Toggle" +"DPI Up": "DPI Up" +"DPI Down": "DPI Down" +"DPI Shift": "DPI Shift" +"Wheel Left": "Wheel Left" +"Wheel Right": "Wheel Right" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Thumb Wheel" "Gesture Button": "Gesture Button" +"View 1": "View 1" +"View 2": "View 2" "Up": "Up" "Down": "Down" "Left": "Left" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index 9374362f..4100d4b1 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Asignar %{name}" "Unbound": "Sin asignar" "Default": "Predeterminado" +"Native": "Nativo" "5 directions": "5 direcciones" "Middle": "Central" "DPI Preset %{index}": "Preajuste de DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Atrás" "Forward": "Adelante" "DPI Toggle": "Cambiar DPI" +"DPI Up": "Subir DPI" +"DPI Down": "Bajar DPI" +"DPI Shift": "Cambio de DPI" +"Wheel Left": "Rueda izquierda" +"Wheel Right": "Rueda derecha" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Rueda lateral" "Gesture Button": "Botón de gestos" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Arriba" "Down": "Abajo" "Left": "Izquierda" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index 44fe3b8c..075ceb92 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Määritä %{name}" "Unbound": "Ei määritetty" "Default": "Oletus" +"Native": "Natiivi" "5 directions": "5 suuntaa" "Middle": "Keskipainike" "DPI Preset %{index}": "DPI-esiasetus %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Takaisin" "Forward": "Eteenpäin" "DPI Toggle": "DPI-vaihto" +"DPI Up": "DPI ylös" +"DPI Down": "DPI alas" +"DPI Shift": "DPI-vaihto" +"Wheel Left": "Rulla vasemmalle" +"Wheel Right": "Rulla oikealle" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Peukalorulla" "Gesture Button": "Eletoiminto" +"View 1": "Näkymä 1" +"View 2": "Näkymä 2" "Up": "Ylös" "Down": "Alas" "Left": "Vasen" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 1317cd65..9b20cdc8 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Attribuer %{name}" "Unbound": "Non attribué" "Default": "Par défaut" +"Native": "Natif" "5 directions": "5 directions" "Middle": "Milieu" "DPI Preset %{index}": "Préréglage DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Précédent" "Forward": "Suivant" "DPI Toggle": "Bascule DPI" +"DPI Up": "Augmenter DPI" +"DPI Down": "Réduire DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Molette gauche" +"Wheel Right": "Molette droite" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Molette latérale" "Gesture Button": "Bouton de gestes" +"View 1": "Vue 1" +"View 2": "Vue 2" "Up": "Haut" "Down": "Bas" "Left": "Gauche" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 08b290b7..13b2af91 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Associa %{name}" "Unbound": "Non associato" "Default": "Predefinito" +"Native": "Nativo" "5 directions": "5 direzioni" "Middle": "Centrale" "DPI Preset %{index}": "Preset DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Indietro" "Forward": "Avanti" "DPI Toggle": "Cambia DPI" +"DPI Up": "Aumenta DPI" +"DPI Down": "Riduci DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Rotella a sinistra" +"Wheel Right": "Rotella a destra" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Volante da pollice" "Gesture Button": "Pulsante gesture" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Su" "Down": "Giù" "Left": "Sinistra" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index 61c9d9d0..cc630140 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} を割り当て" "Unbound": "未割り当て" "Default": "デフォルト" +"Native": "ネイティブ" "5 directions": "5 方向" "Middle": "中ボタン" "DPI Preset %{index}": "DPI プリセット %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "戻る" "Forward": "進む" "DPI Toggle": "DPI 切り替え" +"DPI Up": "DPI 上げる" +"DPI Down": "DPI 下げる" +"DPI Shift": "DPI シフト" +"Wheel Left": "ホイール左" +"Wheel Right": "ホイール右" +"Smart Shift": "Smart Shift" "Thumb Wheel": "サムホイール" "Gesture Button": "ジェスチャーボタン" +"View 1": "ビュー 1" +"View 2": "ビュー 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index c4504c0e..f8dbd264 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} 지정" "Unbound": "할당되지 않음" "Default": "기본값" +"Native": "기본 동작" "5 directions": "5방향" "Middle": "가운데 버튼" "DPI Preset %{index}": "DPI 프리셋 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "뒤로" "Forward": "앞으로" "DPI Toggle": "DPI 전환" +"DPI Up": "DPI 올리기" +"DPI Down": "DPI 내리기" +"DPI Shift": "DPI 시프트" +"Wheel Left": "휠 왼쪽" +"Wheel Right": "휠 오른쪽" +"Smart Shift": "Smart Shift" "Thumb Wheel": "썸휠" "Gesture Button": "제스처 버튼" +"View 1": "보기 1" +"View 2": "보기 2" "Up": "위" "Down": "아래" "Left": "왼쪽" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index 2f0e55c4..87ab95c9 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Tilordne %{name}" "Unbound": "Ikke tildelt" "Default": "Standard" +"Native": "Innebygd" "5 directions": "5 retninger" "Middle": "Midtknapp" "DPI Preset %{index}": "DPI-forhåndsinnstilling %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Tilbake" "Forward": "Frem" "DPI Toggle": "DPI-veksling" +"DPI Up": "DPI opp" +"DPI Down": "DPI ned" +"DPI Shift": "DPI Shift" +"Wheel Left": "Hjul venstre" +"Wheel Right": "Hjul høyre" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Tommelhjul" "Gesture Button": "Bevegelsesknapp" +"View 1": "Visning 1" +"View 2": "Visning 2" "Up": "Opp" "Down": "Ned" "Left": "Venstre" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 51f32483..5ce23bf7 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "%{name} toewijzen" "Unbound": "Niet toegewezen" "Default": "Standaard" +"Native": "Systeemeigen" "5 directions": "5 richtingen" "Middle": "Middenknop" "DPI Preset %{index}": "DPI-voorinstelling %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Vorige" "Forward": "Volgende" "DPI Toggle": "DPI wisselen" +"DPI Up": "DPI omhoog" +"DPI Down": "DPI omlaag" +"DPI Shift": "DPI-shift" +"Wheel Left": "Wiel links" +"Wheel Right": "Wiel rechts" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Duimwiel" "Gesture Button": "Gebarenknop" +"View 1": "Weergave 1" +"View 2": "Weergave 2" "Up": "Omhoog" "Down": "Omlaag" "Left": "Links" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index fbec6b45..99a6762d 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Przypisz %{name}" "Unbound": "Nieprzypisane" "Default": "Domyślne" +"Native": "Natywne" "5 directions": "5 kierunków" "Middle": "Środkowy" "DPI Preset %{index}": "Ustawienie wstępne DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Wstecz" "Forward": "Do przodu" "DPI Toggle": "Przełącznik DPI" +"DPI Up": "Zwiększ DPI" +"DPI Down": "Zmniejsz DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Kółko w lewo" +"Wheel Right": "Kółko w prawo" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Rolka kciukowa" "Gesture Button": "Przycisk gestów" +"View 1": "Widok 1" +"View 2": "Widok 2" "Up": "W górę" "Down": "W dół" "Left": "W lewo" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index 4508e5ae..f10658fc 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Atribuir %{name}" "Unbound": "Não atribuído" "Default": "Padrão" +"Native": "Nativo" "5 directions": "5 direções" "Middle": "Meio" "DPI Preset %{index}": "Predefinição de DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Voltar" "Forward": "Avançar" "DPI Toggle": "Alternar DPI" +"DPI Up": "Aumentar DPI" +"DPI Down": "Diminuir DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Roda para a esquerda" +"Wheel Right": "Roda para a direita" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Roda do Polegar" "Gesture Button": "Botão de Gesto" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Cima" "Down": "Baixo" "Left": "Esquerda" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index 0a13f08c..7770d095 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Atribuir %{name}" "Unbound": "Não atribuído" "Default": "Predefinição" +"Native": "Nativo" "5 directions": "5 direções" "Middle": "Central" "DPI Preset %{index}": "Predefinição de DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Retroceder" "Forward": "Avançar" "DPI Toggle": "Alternar DPI" +"DPI Up": "Aumentar DPI" +"DPI Down": "Reduzir DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Roda à esquerda" +"Wheel Right": "Roda à direita" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Roda de polegar" "Gesture Button": "Botão de gesto" +"View 1": "Vista 1" +"View 2": "Vista 2" "Up": "Cima" "Down": "Baixo" "Left": "Esquerda" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 32e1be75..2ef42e99 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Назначить %{name}" "Unbound": "Не назначено" "Default": "По умолчанию" +"Native": "Системное" "5 directions": "5 направлений" "Middle": "Средняя" "DPI Preset %{index}": "Пресет DPI %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Назад" "Forward": "Вперёд" "DPI Toggle": "Переключение DPI" +"DPI Up": "Повысить DPI" +"DPI Down": "Понизить DPI" +"DPI Shift": "DPI Shift" +"Wheel Left": "Колесо влево" +"Wheel Right": "Колесо вправо" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Колесо под большой палец" "Gesture Button": "Кнопка жестов" +"View 1": "Вид 1" +"View 2": "Вид 2" "Up": "Вверх" "Down": "Вниз" "Left": "Влево" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index cc6cf27f..a35a707d 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "Bind %{name}" "Unbound": "Ej tilldelad" "Default": "Standard" +"Native": "Inbyggd" "5 directions": "5 riktningar" "Middle": "Mittenknapp" "DPI Preset %{index}": "DPI-förinställning %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "Bakåt" "Forward": "Framåt" "DPI Toggle": "DPI-växling" +"DPI Up": "DPI upp" +"DPI Down": "DPI ned" +"DPI Shift": "DPI-shift" +"Wheel Left": "Hjul vänster" +"Wheel Right": "Hjul höger" +"Smart Shift": "Smart Shift" "Thumb Wheel": "Tumhjul" "Gesture Button": "Gestknapp" +"View 1": "Vy 1" +"View 2": "Vy 2" "Up": "Upp" "Down": "Ned" "Left": "Vänster" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index aa217b46..cf906b91 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "绑定 %{name}" "Unbound": "未绑定" "Default": "默认" +"Native": "原生" "5 directions": "5 个方向" "Middle": "中键" "DPI Preset %{index}": "灵敏度预设 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "后退" "Forward": "前进" "DPI Toggle": "灵敏度切换" +"DPI Up": "灵敏度提高" +"DPI Down": "灵敏度降低" +"DPI Shift": "灵敏度切换键" +"Wheel Left": "滚轮左倾" +"Wheel Right": "滚轮右倾" +"Smart Shift": "智能切换" "Thumb Wheel": "拇指滚轮" "Gesture Button": "手势按钮" +"View 1": "视图 1" +"View 2": "视图 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index e11ad21a..235eba36 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "綁定 %{name}" "Unbound": "未綁定" "Default": "預設" +"Native": "原生" "5 directions": "5 個方向" "Middle": "中鍵" "DPI Preset %{index}": "靈敏度預設 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "後退" "Forward": "前進" "DPI Toggle": "靈敏度切換" +"DPI Up": "靈敏度提高" +"DPI Down": "靈敏度降低" +"DPI Shift": "靈敏度切換鍵" +"Wheel Left": "滾輪左傾" +"Wheel Right": "滾輪右傾" +"Smart Shift": "智能切換" "Thumb Wheel": "拇指滾輪" "Gesture Button": "手勢按鈕" +"View 1": "視圖 1" +"View 2": "視圖 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index 5d51239b..36f2267b 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -136,6 +136,7 @@ _version: 1 "Bind %{name}": "設定 %{name}" "Unbound": "未設定" "Default": "預設" +"Native": "原生" "5 directions": "5 個方向" "Middle": "中鍵" "DPI Preset %{index}": "靈敏度預設 %{index}" @@ -148,8 +149,16 @@ _version: 1 "Back": "後退" "Forward": "前進" "DPI Toggle": "靈敏度切換" +"DPI Up": "靈敏度提高" +"DPI Down": "靈敏度降低" +"DPI Shift": "靈敏度切換鍵" +"Wheel Left": "滾輪左傾" +"Wheel Right": "滾輪右傾" +"Smart Shift": "智慧切換" "Thumb Wheel": "拇指滾輪" "Gesture Button": "手勢按鈕" +"View 1": "檢視 1" +"View 2": "檢視 2" "Up": "上" "Down": "下" "Left": "左" diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 5a171e9d..1cab8c95 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -18,6 +18,7 @@ use gpui_component::{ }; use openlogi_core::device::{ BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind, + is_g502_family, }; use openlogi_hid::DeviceRoute; use tracing::info; @@ -95,8 +96,7 @@ impl DetailTab { let caps = record .capabilities .unwrap_or_else(|| Capabilities::presumed_from_kind(record.kind)); - let can_show_mouse_model = record.asset.is_some() - || matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball); + let can_show_mouse_model = has_mouse_model_surface(record); let mut tabs = Vec::new(); if caps.buttons && can_show_mouse_model { tabs.push(Self::Buttons); @@ -129,6 +129,15 @@ impl DetailTab { } } +fn has_mouse_model_surface(record: &DeviceRecord) -> bool { + let name = record.codename.as_deref().unwrap_or(&record.display_name); + record.asset.is_some() + || matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball) + // G502 can be identified from model info even when 0x0005 kind fails; + // it has a family-authored mouse model, so don't depend on kind here. + || is_g502_family(record.model_info.as_ref(), Some(name)) +} + /// Root application view. pub struct AppView { focus_handle: FocusHandle, @@ -1728,6 +1737,7 @@ mod tests { use super::{ Capabilities, DetailTab, DeviceKind, DeviceRecord, DeviceRoute, connection_icon_path, }; + use openlogi_core::device::{DeviceModelInfo, DeviceTransports}; #[test] fn connection_icon_matches_route() { @@ -1841,4 +1851,25 @@ mod tests { let tabs = DetailTab::tabs_for(&record(DeviceKind::Unknown, None)); assert_eq!(tabs, vec![DetailTab::Device]); } + + #[test] + fn g502_model_info_shows_buttons_even_when_kind_is_unknown() { + let mut g502 = record( + DeviceKind::Unknown, + Some(Capabilities { + buttons: true, + ..Capabilities::default() + }), + ); + g502.model_info = Some(DeviceModelInfo { + entity_count: 1, + serial_number: None, + unit_id: [0; 4], + transports: DeviceTransports::default(), + model_ids: [0x4099, 0xc095, 0], + extended_model_id: 0, + }); + + assert!(DetailTab::tabs_for(&g502).contains(&DetailTab::Buttons)); + } } diff --git a/crates/openlogi-gui/src/mouse_model/button_layout.rs b/crates/openlogi-gui/src/mouse_model/button_layout.rs new file mode 100644 index 00000000..61e3b7ff --- /dev/null +++ b/crates/openlogi-gui/src/mouse_model/button_layout.rs @@ -0,0 +1,354 @@ +//! Device-family-specific button layouts for the mouse model. +//! +//! The G502 family does not expose the MX-line `0x1b04` reprogrammable-control +//! table, so this layout is model-authored rather than discovered from firmware. + +use openlogi_core::device::{DeviceModelInfo, is_g502_family}; + +use crate::data::mouse_buttons::{ButtonId, Hotspot, default_hotspots}; +use crate::mouse_model::leader_lines::{Label, Side}; + +/// G Hub splits the G502 button map into top and thumb-side perspectives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MouseModelPerspective { + #[default] + View1, + View2, +} + +impl MouseModelPerspective { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::View1 => "View 1", + Self::View2 => "View 2", + } + } +} + +/// Which fallback/hotspot policy the mouse model should use. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MouseButtonLayout { + #[default] + Default, + G502, +} + +impl MouseButtonLayout { + #[must_use] + pub fn for_device(model: Option<&DeviceModelInfo>, name: Option<&str>) -> Self { + if is_g502_family(model, name) { + Self::G502 + } else { + Self::Default + } + } + + #[must_use] + pub fn keeps_hotspot(self, id: ButtonId) -> bool { + match self { + Self::Default => true, + Self::G502 => matches!( + id, + ButtonId::MiddleClick + | ButtonId::Back + | ButtonId::Forward + | ButtonId::DpiUp + | ButtonId::DpiDown + | ButtonId::DpiShift + | ButtonId::WheelLeft + | ButtonId::WheelRight + | ButtonId::SmartShift + ), + } + } + + #[must_use] + pub fn binds_hotspot(self, id: ButtonId) -> bool { + match self { + Self::Default => true, + // G502 extras need the gaming-button protocol before a saved binding + // can actually fire; keep them visible but not editable for now. + Self::G502 => matches!( + id, + ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward + ), + } + } + + #[must_use] + pub fn supports_perspectives(self) -> bool { + matches!(self, Self::G502) + } + + #[must_use] + pub fn fallback_hotspots(self, perspective: MouseModelPerspective) -> Vec { + match self { + Self::Default => default_hotspots(), + Self::G502 => g502_hotspots(perspective), + } + } + + #[must_use] + pub fn fallback_labels(self, perspective: MouseModelPerspective) -> Vec