Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
15 changes: 14 additions & 1 deletion crates/openlogi-agent-core/src/device_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/openlogi-agent-core/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions crates/openlogi-agent-core/tests/wire_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fn assert_wire<T: serde::Serialize>(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
Expand Down Expand Up @@ -154,6 +154,7 @@ fn device_inventory() {
}),
capabilities: Some(Capabilities {
buttons: true,
native_button_capture: true,
pointer: true,
lighting: false,
scroll_inversion: false,
Expand All @@ -162,7 +163,7 @@ fn device_inventory() {
}];
assert_wire(
&inventory,
"010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b0101010000",
"010d426f6c74205265636569766572fb6d04fb48c501084630304443414645010101094d58204d535452335301fb34b000010150020001030106323134304c5a0102030400010100fb34b0fb8240000b010101010000",
);
}

Expand Down Expand Up @@ -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<DpiInfo, WriteError> = Ok(DpiInfo {
current: 1600,
capabilities: DpiCapabilities::new(vec![800, 1600, 3200]).expect("non-empty list"),
Expand Down
4 changes: 2 additions & 2 deletions crates/openlogi-cli/src/cmd/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,7 @@ mod tests {
kind: DeviceKind::Mouse,
capabilities: Capabilities {
buttons: true,
native_button_capture: true,
pointer: true,
lighting: false,
scroll_inversion: false,
Expand Down
31 changes: 27 additions & 4 deletions crates/openlogi-core/src/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand All @@ -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
Expand All @@ -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,
}
}
Expand All @@ -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,
Expand Down Expand Up @@ -290,6 +305,7 @@ mod tests {
mouse,
Capabilities {
buttons: true,
native_button_capture: true,
pointer: true,
lighting: false,
scroll_inversion: false,
Expand All @@ -301,6 +317,7 @@ mod tests {
keyboard,
Capabilities {
buttons: false,
native_button_capture: false,
pointer: false,
lighting: true,
scroll_inversion: false,
Expand All @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/openlogi-core/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub enum AssetSource {
pub enum ConnectionKind {
BoltReceiver,
UnifyingReceiver,
LightspeedReceiver,
BluetoothDirect,
Wired,
Unknown,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -541,6 +543,7 @@ mod tests {
battery: None,
capabilities: Some(Capabilities {
buttons: true,
native_button_capture: true,
pointer: true,
lighting: false,
scroll_inversion: false,
Expand Down
4 changes: 4 additions & 0 deletions crates/openlogi-gui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-gui/src/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 41 additions & 4 deletions crates/openlogi-gui/src/mouse_model/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ impl MouseModelView {

impl Render for MouseModelView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (asset, active, bindings, gesture_owner, glow) = cx
let (asset, active, bindings, gesture_owner, glow, native_button_capture) = cx
.try_global::<AppState>()
.map(|s| {
(
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -215,15 +224,17 @@ fn scaled_model(
asset: Option<&ResolvedAsset>,
target_h: f32,
max_w: f32,
native_button_capture: bool,
) -> (f32, f32, Vec<Hotspot>, Vec<Label>) {
if let Some(a) = asset {
let (w, h) = asset_dimensions_for_png(a, target_h, max_w);
let hotspots = asset_hotspots_for_png(a, w, h);
let mut hotspots = asset_hotspots_for_png(a, w, h);
retain_remappable_hotspots(&mut hotspots, native_button_capture);
let labels = labels_from_hotspots(&hotspots, h);
(w, h, hotspots, labels)
} else {
let scale = (target_h / MOUSE_MODEL_SIZE.1).min(max_w / MOUSE_MODEL_SIZE.0);
let hotspots = default_hotspots()
let mut hotspots = default_hotspots()
.into_iter()
.map(|hs| Hotspot {
x: hs.x * scale,
Expand All @@ -233,8 +244,10 @@ fn scaled_model(
..hs
})
.collect();
retain_remappable_hotspots(&mut hotspots, native_button_capture);
let labels = default_labels()
.into_iter()
.filter(|label| hotspots.iter().any(|hotspot| hotspot.id == label.id))
.map(|l| Label {
y: l.y * scale,
..l
Expand All @@ -249,6 +262,12 @@ fn scaled_model(
}
}

fn retain_remappable_hotspots(hotspots: &mut Vec<Hotspot>, native_button_capture: bool) {
if !native_button_capture {
hotspots.retain(|hotspot| hotspot.id.is_os_hook_button());
}
}

/// The gesture-capable buttons present on this device, in a stable display
/// order: the HID++ gesture button first, then the OS-hook Middle/Back/Forward.
fn gesture_capable_buttons(labels: &[Label]) -> Vec<ButtonId> {
Expand Down Expand Up @@ -824,4 +843,22 @@ mod tests {
"Gesture Button"
);
}

#[test]
fn host_only_mouse_exposes_only_os_visible_hotspots() {
let mut hotspots = default_hotspots();
retain_remappable_hotspots(&mut hotspots, false);
assert_eq!(
hotspots
.iter()
.map(|hotspot| hotspot.id)
.collect::<Vec<_>>(),
[ButtonId::MiddleClick, ButtonId::Back, ButtonId::Forward]
);
assert!(
!hotspots
.iter()
.any(|hotspot| matches!(hotspot.id, ButtonId::DpiToggle | ButtonId::GestureButton))
);
}
}
1 change: 1 addition & 0 deletions crates/openlogi-gui/src/state/devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ mod tests {
kind: DeviceKind::Mouse,
capabilities: Capabilities {
buttons: true,
native_button_capture: true,
pointer: true,
lighting: false,
scroll_inversion: false,
Expand Down
Loading