Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7862146
feat(hidpp): add typed device control features
AprilNEA Jun 27, 2026
ec132b7
refactor(hid): use hidpp smartshift enhanced wrapper
AprilNEA Jun 27, 2026
1ca31c0
feat(hidpp): add multi-host feature wrappers
AprilNEA Jun 27, 2026
5351b0b
feat(hidpp): add extended dpi and mouse pointer wrappers
AprilNEA Jun 27, 2026
64f02bd
feat(hidpp): add host switching and keyboard key wrappers
AprilNEA Jun 27, 2026
cc6d907
feat(hidpp): add backlight and illumination wrappers
AprilNEA Jun 27, 2026
0ffb5c6
feat(hidpp): add color led effects wrapper
AprilNEA Jun 27, 2026
a78791a
feat(hidpp): add rgb effects and per-key lighting wrappers
AprilNEA Jun 27, 2026
704e524
refactor(hid): use hidpp color led effects wrapper for lighting
AprilNEA Jun 27, 2026
a8ad21f
docs(hid): clarify MX gesture control semantics
AprilNEA Jun 27, 2026
8a6c1dd
feat(hidpp): add audio equalizer wrapper
AprilNEA Jun 27, 2026
9f5253e
feat(hidpp): add persistent remappable action wrapper
AprilNEA Jun 27, 2026
80b5c09
feat(hidpp): add crown wrapper
AprilNEA Jun 27, 2026
4fdf710
feat(hidpp): add raw touch wrappers
AprilNEA Jun 27, 2026
4f1c551
feat(hidpp): add solar keyboard dashboard wrapper
AprilNEA Jun 27, 2026
488bed1
feat(hidpp): add control and task id constants
AprilNEA Jun 27, 2026
7b2190f
docs(hidpp): document feature coverage in crate READMEs
AprilNEA Jun 27, 2026
50d5b5b
fix(hidpp): validate typed feature inputs
AprilNEA Jun 27, 2026
bdcfa8a
fix(hidpp): address typed wrapper review notes
AprilNEA Jun 27, 2026
0083767
fix(hid): preserve zero SmartShift torque on toggle
AprilNEA Jun 27, 2026
562fea9
fix(hidpp): correct multi-host fn inversion arg order
AprilNEA Jun 27, 2026
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ Things OpenLogi does that Options+ won't:
Linux as a first-class platform: evdev/uinput hook, udev rules, a systemd
user unit, and `.deb` / `.rpm` packages.
- **Move the Gesture Button.** Pick which physical button owns the gesture
role — thumb pad, middle, back, or forward — with per-direction swipe
role — the dedicated Gesture Button, middle, back, or forward — with per-direction swipe
bindings, or turn gestures off entirely. Options+ pins the gesture role to
the dedicated thumb pad.
the dedicated Gesture Button.
- **Keep config in plain text.** Everything is one TOML file you can read,
diff, version-control, and copy between machines.
- **Script it.** A real CLI: device inventory, asset prefetch, and on-device
Expand Down
16 changes: 8 additions & 8 deletions crates/openlogi-agent-core/src/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ pub fn gesture_bindings_for(
config: &Config,
config_key: Option<&str>,
) -> BTreeMap<GestureDirection, Action> {
// The thumb pad (HID++ 0x00c3) only gestures while it is the device's gesture
// The dedicated HID++ gesture button (CID 0x00c3) only gestures while it is the device's gesture
// owner. When the user moves the role to an OS-hook button (Middle/Back/
// Forward) or turns gestures off, return an empty map so the gesture watcher
// dispatches nothing — otherwise the always-seeded defaults would keep the
// thumb pad firing regardless of the selection.
// HID++ gesture button firing regardless of the selection.
let owner = config_key.and_then(|key| config.gesture_owner(key));
if owner != Some(ButtonId::GestureButton) {
return BTreeMap::new();
Expand Down Expand Up @@ -105,7 +105,7 @@ pub fn oshook_gestures_for(
return BTreeMap::new();
};
// Only an OS-hook button (Middle/Back/Forward) as the device's gesture owner
// feeds the OS hook: the thumb pad is captured over HID++, and a non-owner
// feeds the OS hook: the dedicated HID++ gesture button is captured over HID++, and a non-owner
// button is dispatched as its single click action. Returning *only* the owner
// keeps the runtime in lockstep with `gesture_owner` and the GUI, so a stray
// second gesture map (e.g. a hand-edited config) can't make two buttons fire.
Expand Down Expand Up @@ -223,22 +223,22 @@ mod tests {
}

#[test]
fn gesture_bindings_silent_when_thumb_pad_is_not_the_owner() {
fn gesture_bindings_silent_when_hidpp_button_is_not_the_owner() {
let mut cfg = Config::default();
// Default device: the thumb pad owns gestures, so its defaults are seeded.
// Default device: the dedicated HID++ gesture button owns gestures, so its defaults are seeded.
let defaults = gesture_bindings_for(&cfg, Some("2b042"));
assert_eq!(
defaults.get(&GestureDirection::Up),
Some(&default_gesture_binding(GestureDirection::Up)),
"the default gesture owner is the thumb pad"
"the default gesture owner is the dedicated HID++ gesture button"
);

// Move the gesture role to an OS-hook button: the thumb pad goes silent,
// Move the gesture role to an OS-hook button: the HID++ gesture button goes silent,
// so the watcher dispatches nothing for 0x00c3.
cfg.set_gesture_owner("2b042", ButtonId::Back);
assert!(
gesture_bindings_for(&cfg, Some("2b042")).is_empty(),
"thumb pad must dispatch nothing once another button owns gestures"
"HID++ gesture button must dispatch nothing once another button owns gestures"
);
}
}
2 changes: 1 addition & 1 deletion crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub type SharedHookMaps = Arc<RwLock<HookMaps>>;

/// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the
/// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe
/// *mid-motion* like the HID++ thumb-pad path in `openlogi-hid`. This wrapper
/// *mid-motion* like the HID++ gesture-button path in `openlogi-hid`. This wrapper
/// adds only the button identity the accumulator doesn't track; a press that
/// never commits a direction is a plain click, fired on release.
#[derive(Default)]
Expand Down
2 changes: 1 addition & 1 deletion crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ impl Orchestrator {
}

/// Foreground-app change → re-overlay per-app bindings on the hook maps (DPI
/// and the thumb-pad gesture map are not app-scoped, so they're untouched).
/// and the dedicated HID++ gesture map are not app-scoped, so they're untouched).
/// Both hook maps are recomputed: a per-app override of the gesture owner
/// turns it into a single action for that app, dropping it from the OS-hook
/// gesture set — so the gesture map is app-scoped too.
Expand Down
2 changes: 1 addition & 1 deletion crates/openlogi-agent-core/src/watchers/gesture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ async fn manage(
} else {
let target = dpi_cycle.read().ok().and_then(|guard| guard.target.clone());
let sensitivity = thumbwheel_sensitivity.load(Ordering::Relaxed);
// Divert the thumb pad only while it owns the gesture role. The
// Divert the dedicated HID++ gesture button only while it owns the gesture role. The
// shared gesture map is non-empty exactly then (gesture_bindings_for
// gates on the owner), so it doubles as that signal — no need to
// thread the full config in. Re-evaluated each tick, so a
Expand Down
6 changes: 3 additions & 3 deletions crates/openlogi-core/src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
}

/// The mid-swipe state machine shared by both gesture-capture paths: the HID++
/// thumb pad (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook
/// dedicated gesture button (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook
/// Middle/Back/Forward buttons (`openlogi-agent-core`'s CGEventTap). A gesture
/// button's hold accumulates travel; the instant the dominant axis commits a
/// direction — after the button has been held [`GESTURE_HOLD_FOR_SWIPE`], so a
Expand All @@ -216,7 +216,7 @@ pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
/// commits is a plain click, reported by [`Self::end`].
///
/// The two paths differ only in *what identifies the held control* (a
/// [`ButtonId`] for the OS hook, a diverted CID for the thumb pad), so each owns
/// [`ButtonId`] for the OS hook, a diverted CID for the HID++ gesture control), so each owns
/// that and embeds this for the shared travel logic. Keeping the logic in one
/// place is deliberate: the two copies it replaced had already drifted apart
/// (one resolved a swipe only on release), which mis-fired the click.
Expand Down Expand Up @@ -845,7 +845,7 @@ impl Action {
///
/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
/// MX-line devices: thumb wheel click → App Exposé, gesture button →
/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
/// Mission Control. The thumb wheel isn't captured yet; the dedicated gesture button is
/// (per-direction, see [`default_gesture_binding`]). The bindings persist
/// regardless so the user only configures once.
///
Expand Down
40 changes: 20 additions & 20 deletions crates/openlogi-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,9 @@ pub struct DeviceIdentity {
#[serde(from = "RawDeviceConfig")]
pub struct DeviceConfig {
/// Which button owns the device's single gesture role, once the user has
/// chosen explicitly. Absent means "infer" (the thumb pad owns gestures if
/// present) — see [`Config::gesture_owner`]. Listed first so it serializes
/// as a scalar ahead of the `bindings` sub-table.
/// chosen explicitly. Absent means "infer" (the dedicated HID++ gesture
/// button owns gestures if present) — see [`Config::gesture_owner`]. Listed
/// first so it serializes as a scalar ahead of the `bindings` sub-table.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gesture_owner: Option<GestureOwner>,
/// Last-known identity (name / kind / capabilities), captured while the
Expand Down Expand Up @@ -663,13 +663,13 @@ impl Config {
///
/// Resolved from the explicit [`DeviceConfig::gesture_owner`] when present;
/// otherwise inferred (see `Self::infer_gesture_owner`) for configs
/// predating the field and freshly-migrated pre-v2 files. The dedicated thumb
/// pad ([`ButtonId::GestureButton`]) owns the role by default. At most one
/// button gestures per device.
/// predating the field and freshly-migrated pre-v2 files. The dedicated
/// HID++ gesture button ([`ButtonId::GestureButton`]) owns the role by
/// default. At most one button gestures per device.
#[must_use]
pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
let Some(device) = self.devices.get(device_key) else {
// No config yet → the thumb pad is the default gesture owner.
// No config yet → the dedicated HID++ gesture button is the default gesture owner.
return Some(ButtonId::GestureButton);
};
match device.gesture_owner {
Expand All @@ -691,14 +691,14 @@ impl Config {
{
return Some(*id);
}
// A thumb pad explicitly demoted to a single action means gestures off.
// A dedicated HID++ gesture button explicitly demoted to a single action means gestures off.
if matches!(
bindings.get(&ButtonId::GestureButton),
Some(Binding::Single(_))
) {
return None;
}
// Default: the thumb pad owns the gesture role.
// Default: the dedicated HID++ gesture button owns the gesture role.
Some(ButtonId::GestureButton)
}

Expand Down Expand Up @@ -1471,12 +1471,12 @@ Back = \"BrowserBack\"
}

#[test]
fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
let mut cfg = Config::default();
// Default: the thumb pad owns the gesture role even with no config.
// Default: the dedicated HID++ gesture button owns the gesture role even with no config.
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));

// A thumb-pad gesture binding keeps it the owner.
// A dedicated HID++ gesture binding keeps it the owner.
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
Expand All @@ -1493,7 +1493,7 @@ Back = \"BrowserBack\"
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));

// Turning gestures off explicitly yields `None` (not the thumb-pad default).
// Turning gestures off explicitly yields `None` (not the HID++ button default).
let mut off = Config::default();
off.disable_gestures("2b042");
assert_eq!(off.gesture_owner("2b042"), None);
Expand All @@ -1502,7 +1502,7 @@ Back = \"BrowserBack\"
#[test]
fn set_gesture_owner_records_owner_without_destroying_other_maps() {
let mut cfg = Config::default();
// Customize the thumb pad's Up swipe; it is the (inferred) owner.
// Customize the dedicated HID++ gesture button's Up swipe; it is the (inferred) owner.
cfg.set_gesture_direction(
"2b042",
ButtonId::GestureButton,
Expand All @@ -1511,7 +1511,7 @@ Back = \"BrowserBack\"
);
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));

// Promote Back: the owner becomes Back explicitly; the thumb pad keeps
// Promote Back: the owner becomes Back explicitly; the HID++ gesture button keeps
// its full gesture map (no destructive demotion).
cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
cfg.set_gesture_owner("2b042", ButtonId::Back);
Expand All @@ -1534,12 +1534,12 @@ Back = \"BrowserBack\"
}
other => panic!("expected Back to be a gesture binding, got {other:?}"),
}
// The thumb pad's customized map survived the switch intact.
// The HID++ gesture button's customized map survived the switch intact.
match bindings.get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
}
other => panic!("expected the thumb pad map preserved, got {other:?}"),
other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
}

// Switching back restores the user's customization, not defaults
Expand All @@ -1557,7 +1557,7 @@ Back = \"BrowserBack\"
#[test]
fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
let mut cfg = Config::default();
// The dedicated thumb pad gets the full default direction map.
// The dedicated HID++ gesture button gets the full default direction map.
cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Some(Binding::Gesture(map)) => {
Expand Down Expand Up @@ -1601,7 +1601,7 @@ Back = \"BrowserBack\"
Action::Copy,
);
cfg.disable_gestures("2b042");
// Off, but the thumb pad's customized map is preserved (re-enabling
// Off, but the HID++ gesture button's customized map is preserved (re-enabling
// restores it rather than resurrecting a wiped default).
assert_eq!(cfg.gesture_owner("2b042"), None);
match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
Expand Down Expand Up @@ -1654,7 +1654,7 @@ Back = \"Copy\"
cfg.bindings_for("2b042").get(&ButtonId::Back),
Some(&Binding::Single(Action::Copy))
);
// ...and the bad owner degraded to inference (thumb-pad default here).
// ...and the bad owner degraded to inference (HID++ button default here).
assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
}
}
6 changes: 6 additions & 0 deletions crates/openlogi-hid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@ Public entry points include:
- `list_pairing_receivers`, `run_pairing`, and `unpair` for receiver pairing.
- `get_dpi`, `set_dpi`, SmartShift, high-resolution wheel, thumbwheel, and
reprogrammable-control helpers for supported HID++ features.
- `set_keyboard_color` / `set_keyboard_color_with` for solid keyboard RGB —
preferring the typed `ColorLedEffects` (`0x8070`) wrapper and falling back to
the `PerKeyLighting` (`0x8080`) stream.
- `dump_features` and `dump_reprog_controls` for diagnostics.

Protocol-level feature support lives in `openlogi-hidpp`; this crate adds the
discovery, routing, fallback, and error-classification policy on top.
15 changes: 8 additions & 7 deletions crates/openlogi-hid/src/gesture.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Live control capture for one device: divert the MX thumb gesture button, the
//! Live control capture for one device: divert the MX dedicated gesture button, the
//! DPI/ModeShift button, and the thumb wheel over HID++ and turn their events
//! into [`CapturedInput`] the GUI can dispatch.
//!
Expand Down Expand Up @@ -70,7 +70,7 @@ pub enum GestureError {
/// because the channel's read thread invokes the listener by shared reference.
#[derive(Default)]
struct CaptureAccum {
/// Mid-swipe state for the diverted thumb-pad gesture button (raw-XY).
/// Mid-swipe state for the diverted dedicated gesture button (raw-XY).
swipe: SwipeAccumulator,
/// Whether any DPI/ModeShift control was held in the last event — for
/// rising-edge press detection.
Expand All @@ -81,11 +81,12 @@ struct CaptureAccum {
/// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves,
/// forwarding each event to `sink`.
///
/// The gesture button (raw-XY) is diverted only when `divert_gesture_button` —
/// The dedicated gesture button (raw-XY) is diverted only when `divert_gesture_button` —
/// i.e. it is the device's gesture owner. When the user moves the gesture role
/// to an OS-hook button or turns gestures off, the thumb pad is left undiverted
/// so it keeps its native behavior instead of being captured-and-swallowed. The
/// DPI/ModeShift capture and the channel-reuse slot are independent of this.
/// to an OS-hook button or turns gestures off, the HID++ gesture control is
/// left undiverted so it keeps its native behavior instead of being
/// captured-and-swallowed. The DPI/ModeShift capture and the channel-reuse slot
/// are independent of this.
///
/// Opens and holds one HID++ channel, diverts whichever of those controls the
/// device exposes, and listens. Returns once `shutdown` fires (or its sender is
Expand Down Expand Up @@ -231,7 +232,7 @@ async fn arm_controls(
let controls = enumerate_controls(&rc).await?;

// Only divert the gesture button when it owns the gesture role; otherwise
// leave it native (a non-owner thumb pad must not be captured-and-dropped).
// leave it native (a non-owner HID++ control must not be captured-and-dropped).
if divert_gesture_button
&& controls
.iter()
Expand Down
13 changes: 8 additions & 5 deletions crates/openlogi-hid/src/reprog_controls.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
//! HID++ `ReprogControlsV4` (feature `0x1b04`) — temporary control diversion
//! and raw-XY reporting, the mechanism behind the MX-line thumb "gesture
//! button".
//! and raw-XY reporting, the mechanism behind MX-line reprogrammable controls.
//!
//! The full protocol wrapper lives in `openlogi-hidpp`; this module keeps the
//! OpenLogi-facing compatibility API used by gesture/button orchestration:
Expand Down Expand Up @@ -35,9 +34,13 @@ pub use hidpp_reprog::{
/// `ReprogControlsV4` HID++ feature ID.
pub const FEATURE_ID: u16 = 0x1b04;

/// Control ID of the MX-line thumb gesture button (`Mouse_Gesture_Button`,
/// Logitech "App_Switch_Gesture"). Cross-checked against Solaar
/// `special_keys.py`.
/// Control ID of the MX-line dedicated gesture button (`Mouse_Gesture_Button`,
/// Logitech "App_Switch_Gesture").
///
/// MX Master 4 also has a separate Haptic Sense Panel in the thumb area. That
/// panel is not this CID; it must be discovered from the device's `0x1b04`
/// control table and supported explicitly before OpenLogi treats it as a
/// bindable/capturable input.
pub const GESTURE_BUTTON_CID: u16 = 0x00c3;

/// Control IDs of the "DPI / ModeShift" button family. Whichever a device
Expand Down
4 changes: 2 additions & 2 deletions crates/openlogi-hid/src/reprog_controls/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ impl TryFrom<ReprogControlsEvent> for RawControlEvent {
/// `0x1b04` event for `(device_index, feature_index)`.
///
/// Returns `None` for request responses (`software_id != 0`), messages from a
/// different device or feature, and events outside OpenLogi's legacy gesture
/// different device or feature, and events outside OpenLogi's gesture-control
/// pipeline.
#[must_use]
pub fn decode_event(
Expand Down Expand Up @@ -111,7 +111,7 @@ mod tests {
let p = [0u8; 16];
// software_id != 0 marks a request response, not an event.
assert_eq!(decode_event(&event(0, 5, p), 2, 7), None);
// Right device + feature, but an event outside the legacy gesture path.
// Right device + feature, but an event outside the gesture-control path.
assert_eq!(decode_event(&event(2, 0, p), 2, 7), None);
// Wrong feature index.
assert_eq!(decode_event(&event(0, 0, p), 2, 9), None);
Expand Down
Loading
Loading