diff --git a/README.md b/README.md index 8d293dbf..97e561d5 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ Remap buttons, drive DPI and SmartShift, and switch profiles per app — without ## What it is -OpenLogi talks to Logitech HID++ mice over a Logi Bolt receiver — or a -Bluetooth-direct / wired connection — without running Logi Options+. It ships -two binaries: +OpenLogi talks to Logitech HID++ mice over Logi Bolt, Unifying, and LIGHTSPEED +receivers — or a Bluetooth-direct / wired connection — without running Logi +Options+. It ships two binaries: - **[OpenLogi GUI](crates/openlogi-gui)** — a GPUI desktop app: an interactive mouse diagram with clickable hotspots, a per-button action picker (41 built-in actions plus custom keyboard shortcuts authored in the TOML config), DPI presets, a SmartShift panel (wheel mode, sensitivity, permanent ratchet), per-application profile overlays, a device carousel that switches between paired devices live, and a Settings window with a UI localized into 20 languages. - **[OpenLogi CLI](crates/openlogi-cli)** — a CLI for headless inventory (`list`) plus asset-sync and on-device diagnostic subcommands. @@ -72,6 +72,7 @@ Things OpenLogi does that Options+ won't: |---|---| | Discover Bolt receivers + list paired devices (CLI + GUI) | ✅ | | Unifying receivers (older protocol, replaced by Bolt) | ✅ | +| LIGHTSPEED gaming receivers (G305 / `046d:c53f` certified) | ✅ | | Bluetooth-direct / wired devices (no receiver) | ✅ | | Battery percentage / charge state | ✅ (online devices) | | Interactive GUI: carousel, mouse diagram, action picker | ✅ macOS + Linux | diff --git a/crates/openlogi-agent-core/src/device_order.rs b/crates/openlogi-agent-core/src/device_order.rs index 8412b89c..4782a5ca 100644 --- a/crates/openlogi-agent-core/src/device_order.rs +++ b/crates/openlogi-agent-core/src/device_order.rs @@ -61,7 +61,8 @@ impl DeviceStableId { match route { Some( DeviceRoute::Bolt { receiver_uid, slot } - | DeviceRoute::Unifying { receiver_uid, slot }, + | DeviceRoute::Unifying { receiver_uid, slot } + | DeviceRoute::Lightspeed { receiver_uid, slot }, ) => Self::Bolt { receiver_uid: receiver_uid.to_ascii_lowercase(), slot: *slot, @@ -154,6 +155,18 @@ mod tests { ); } + #[test] + fn lightspeed_uses_receiver_backed_stable_identity() { + let route = DeviceRoute::Lightspeed { + receiver_uid: "G305-RX".into(), + slot: 1, + }; + assert_eq!( + DeviceStableId::from_parts(Some(&route), 1, None, [0; 4]).config_key(), + "receiver:g305-rx:slot:1" + ); + } + #[test] fn config_key_is_physical_not_model_scoped() { let route = DeviceRoute::Bolt { diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index 37e79e7c..86fb196f 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize}; /// v6: `Capabilities::scroll_inversion` added. /// v7: pairing commands return typed acceptance errors. /// v8: [`WriteError`] carries typed HID++ operation failures. -pub const PROTOCOL_VERSION: u32 = 8; +pub const PROTOCOL_VERSION: u32 = 9; /// Where the agent's device enumeration stands. The distinction matters /// because an empty inventory list is ambiguous on its own: the GUI must keep diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index e0c159b7..a2818455 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -61,7 +61,7 @@ fn assert_wire(value: &T, golden: &str) { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 8); + assert_eq!(PROTOCOL_VERSION, 9); } /// tarpc encodes the request enum's variant index, so trait *method order* is @@ -154,6 +154,7 @@ fn device_inventory() { }), capabilities: Some(Capabilities { buttons: true, + native_button_capture: true, pointer: true, lighting: false, scroll_inversion: false, @@ -162,7 +163,7 @@ fn device_inventory() { }]; assert_wire( &inventory, - "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b0101010000", + "010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b010101010000", ); } @@ -203,6 +204,13 @@ fn pairing_updates() { #[test] fn device_settings_payloads() { + assert_wire( + &DeviceRoute::Lightspeed { + receiver_uid: "G305-RX".into(), + slot: 1, + }, + "0207473330352d525801", + ); let dpi: Result = Ok(DpiInfo { current: 1600, capabilities: DpiCapabilities::new(vec![800, 1600, 3200]).expect("non-empty list"), diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs index 4f648052..668a3c29 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." + " - Supported receiver families are Logi Bolt, Unifying, and Logitech \ + LIGHTSPEED (including the G305 receiver, PID 0xC53F)." ); std::process::exit(2); } diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index f5da39ba..53140372 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -1204,6 +1204,7 @@ mod tests { kind: DeviceKind::Mouse, capabilities: Capabilities { buttons: true, + native_button_capture: true, pointer: true, lighting: false, scroll_inversion: false, diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs index 583257d1..d261cbb4 100644 --- a/crates/openlogi-core/src/device.rs +++ b/crates/openlogi-core/src/device.rs @@ -73,8 +73,15 @@ 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). + /// A Buttons panel is useful. This includes native HID++ reprogrammable + /// controls (`0x1b00`–`0x1b04`) and gaming control tables (`0x8100` / + /// `0x8110`) whose standard middle/back/forward events can be remapped by + /// the host input hook. pub buttons: bool, + /// The device exposes a HID++ ReprogControls capture feature, allowing + /// controls beyond the OS-visible middle/back/forward buttons to be + /// diverted and remapped. + pub native_button_capture: bool, /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi). pub pointer: bool, /// Solid-colour RGB the lighting panel can actually drive — HID++ @@ -92,7 +99,8 @@ impl Capabilities { /// Membership of a driving feature ID flips the corresponding flag. #[must_use] pub fn from_feature_ids(ids: &[u16]) -> Self { - const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04]; + const NATIVE_BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04]; + const GAMING_BUTTONS: [u16; 2] = [0x8100, 0x8110]; 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 @@ -101,9 +109,15 @@ impl Capabilities { const LIGHTING: [u16; 2] = [0x8080, 0x8070]; let has = |family: &[u16]| ids.iter().any(|id| family.contains(id)); Self { - buttons: has(&BUTTONS), + buttons: has(&NATIVE_BUTTONS) || has(&GAMING_BUTTONS), + native_button_capture: has(&NATIVE_BUTTONS), pointer: has(&POINTER), - lighting: has(&LIGHTING), + // Gaming mice may advertise ColorLedEffects for indicator/profile + // LEDs, but OpenLogi's current lighting panel is keyboard-focused; + // gaming-mouse RGB is explicitly deferred. Keep wired G-series + // keyboards eligible by applying the exclusion only to devices + // that also advertise an adjustable pointer. + lighting: has(&LIGHTING) && !(has(&GAMING_BUTTONS) && has(&POINTER)), scroll_inversion: false, } } @@ -118,6 +132,7 @@ impl Capabilities { match kind { DeviceKind::Mouse | DeviceKind::Trackball => Self { buttons: true, + native_button_capture: false, pointer: true, lighting: false, scroll_inversion: false, @@ -290,6 +305,7 @@ mod tests { mouse, Capabilities { buttons: true, + native_button_capture: true, pointer: true, lighting: false, scroll_inversion: false, @@ -301,6 +317,7 @@ mod tests { keyboard, Capabilities { buttons: false, + native_button_capture: false, pointer: false, lighting: true, scroll_inversion: false, @@ -311,6 +328,12 @@ mod tests { Capabilities::from_feature_ids(&[0x0000, 0x0003]), Capabilities::default() ); + + let gaming_mouse = + Capabilities::from_feature_ids(&[0x0005, 0x1000, 0x2201, 0x8070, 0x8100, 0x8110]); + assert!(gaming_mouse.buttons && gaming_mouse.pointer); + assert!(!gaming_mouse.native_button_capture); + assert!(!gaming_mouse.lighting, "gaming-mouse RGB remains deferred"); } #[test] diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs index fd54a09b..09b13dde 100644 --- a/crates/openlogi-core/src/diagnostics.rs +++ b/crates/openlogi-core/src/diagnostics.rs @@ -24,6 +24,7 @@ pub enum AssetSource { pub enum ConnectionKind { BoltReceiver, UnifyingReceiver, + LightspeedReceiver, BluetoothDirect, Wired, Unknown, @@ -370,6 +371,7 @@ fn connection_label(connection: ConnectionKind) -> &'static str { match connection { ConnectionKind::BoltReceiver => "Logi Bolt receiver", ConnectionKind::UnifyingReceiver => "Logi Unifying receiver", + ConnectionKind::LightspeedReceiver => "Logitech LIGHTSPEED receiver", ConnectionKind::BluetoothDirect => "Bluetooth (direct)", ConnectionKind::Wired => "Wired (USB)", ConnectionKind::Unknown => "unknown", @@ -541,6 +543,7 @@ mod tests { battery: None, capabilities: Some(Capabilities { buttons: true, + native_button_capture: true, pointer: true, lighting: false, scroll_inversion: false, diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index 9b455f7c..833b0a42 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -1406,6 +1406,7 @@ fn route_label(route: Option<&DeviceRoute>) -> String { match route { Some(DeviceRoute::Bolt { .. }) => tr!("Bolt receiver").to_string(), Some(DeviceRoute::Unifying { .. }) => tr!("Unifying receiver").to_string(), + Some(DeviceRoute::Lightspeed { .. }) => tr!("LIGHTSPEED receiver").to_string(), Some(DeviceRoute::Direct { .. }) => tr!("Direct connection").to_string(), None => tr!("Unavailable").to_string(), } @@ -1428,6 +1429,7 @@ fn connection_icon_path( match route { Some(DeviceRoute::Bolt { .. }) => "action-icons/bolt.svg", Some(DeviceRoute::Unifying { .. }) => "action-icons/unifying.svg", + Some(DeviceRoute::Lightspeed { .. }) => "action-icons/unifying.svg", // Explicit arms (not `_`) so a new DeviceRoute variant trips the // compiler here, matching the exhaustive sibling `route_label`. Some(DeviceRoute::Direct { .. }) | None => match transports { @@ -1855,6 +1857,7 @@ mod tests { fn tabs_follow_capabilities_not_kind() { let caps = Some(Capabilities { buttons: true, + native_button_capture: true, pointer: true, lighting: false, scroll_inversion: false, @@ -1873,6 +1876,7 @@ mod tests { fn keyboard_without_asset_hides_buttons_tab() { let caps = Some(Capabilities { buttons: true, + native_button_capture: true, pointer: false, lighting: true, scroll_inversion: false, diff --git a/crates/openlogi-gui/src/diagnostics.rs b/crates/openlogi-gui/src/diagnostics.rs index 5044859a..ae26f9e4 100644 --- a/crates/openlogi-gui/src/diagnostics.rs +++ b/crates/openlogi-gui/src/diagnostics.rs @@ -159,6 +159,7 @@ fn connection_for( match route { Some(DeviceRoute::Bolt { .. }) => ConnectionKind::BoltReceiver, Some(DeviceRoute::Unifying { .. }) => ConnectionKind::UnifyingReceiver, + Some(DeviceRoute::Lightspeed { .. }) => ConnectionKind::LightspeedReceiver, Some(DeviceRoute::Direct { .. }) => match transports { Some(t) if t.bluetooth || t.btle => ConnectionKind::BluetoothDirect, Some(t) if t.usb => ConnectionKind::Wired, diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index c25801d2..8af7004e 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -90,7 +90,7 @@ impl MouseModelView { impl Render for MouseModelView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let (asset, active, bindings, gesture_owner, glow) = cx + let (asset, active, bindings, gesture_owner, glow, native_button_capture) = cx .try_global::() .map(|s| { ( @@ -99,6 +99,15 @@ impl Render for MouseModelView { s.button_bindings.clone(), s.current_gesture_owner(), s.current_record().and_then(|r| keyboard_glow(s, r)), + s.current_record() + .and_then(|r| r.capabilities) + .unwrap_or_else(|| { + s.current_record() + .map_or_else(openlogi_core::device::Capabilities::default, |r| { + openlogi_core::device::Capabilities::presumed_from_kind(r.kind) + }) + }) + .native_button_capture, ) }) .unwrap_or_default(); @@ -118,7 +127,7 @@ impl Render for MouseModelView { (viewport_w - MODEL_HORIZONTAL_RESERVE).clamp(MODEL_MIN_CONTENT_W, MODEL_CONTENT_MAX_W); let max_image_w = (content_w - gutter).max(MODEL_MIN_CONTENT_W / 2.); let (mouse_w, mouse_h, hotspots, labels) = - scaled_model(asset.as_ref(), target_h, max_image_w); + scaled_model(asset.as_ref(), target_h, max_image_w, native_button_capture); let canvas_w = gutter + mouse_w; let canvas_h = mouse_h; @@ -215,15 +224,17 @@ fn scaled_model( asset: Option<&ResolvedAsset>, target_h: f32, max_w: f32, + native_button_capture: bool, ) -> (f32, f32, Vec, Vec