Skip to content
Draft
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
6 changes: 6 additions & 0 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 20 additions & 2 deletions crates/openlogi-agent-core/src/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, Action> = ButtonId::ALL
let mut bindings: BTreeMap<ButtonId, Action> = 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
Expand Down Expand Up @@ -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();
Expand Down
60 changes: 16 additions & 44 deletions crates/openlogi-cli/src/cmd/diag/lighting.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
//! `openlogi diag lighting <RRGGBB>` — 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 <RRGGBB>` — 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,
Expand All @@ -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<String>,

Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/openlogi-cli/src/cmd/diag/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

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."
" - LIGHTSPEED mice are normally already paired to their USB receiver; \
Add Device is only for supported pairing flows."
);
std::process::exit(2);
}
Expand Down
67 changes: 59 additions & 8 deletions crates/openlogi-core/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`).
Expand All @@ -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,
Expand All @@ -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 /
Expand All @@ -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",
Expand Down Expand Up @@ -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,
}
}
Expand Down
Loading