diff --git a/crates/openlogi-cli/src/cmd/diag/dpi.rs b/crates/openlogi-cli/src/cmd/diag/dpi.rs index ba04de4..ea27055 100644 --- a/crates/openlogi-cli/src/cmd/diag/dpi.rs +++ b/crates/openlogi-cli/src/cmd/diag/dpi.rs @@ -14,10 +14,10 @@ pub struct DpiArgs { } pub async fn run(args: DpiArgs) -> Result<()> { - let (uid, slot, name) = first_online_device().await?; - println!("device: {name} (slot {slot}, receiver {uid})"); + let (route, name) = first_online_device().await?; + println!("device: {name} ({route})"); - let before = openlogi_hid::get_dpi(Some(&uid), slot) + let before = openlogi_hid::get_dpi(&route) .await .context("read current DPI")?; println!(" current DPI: {before}"); @@ -37,11 +37,11 @@ pub async fn run(args: DpiArgs) -> Result<()> { } println!(" writing DPI: {target}"); - openlogi_hid::set_dpi(Some(&uid), slot, target) + openlogi_hid::set_dpi(&route, target) .await .context("write DPI")?; - let after = openlogi_hid::get_dpi(Some(&uid), slot) + let after = openlogi_hid::get_dpi(&route) .await .context("read DPI after write")?; println!(" read-back DPI: {after}"); @@ -54,7 +54,7 @@ pub async fn run(args: DpiArgs) -> Result<()> { } println!(" restoring DPI: {before}"); - openlogi_hid::set_dpi(Some(&uid), slot, before) + openlogi_hid::set_dpi(&route, before) .await .context("restore DPI")?; diff --git a/crates/openlogi-cli/src/cmd/diag/features.rs b/crates/openlogi-cli/src/cmd/diag/features.rs index ad7f101..fd2ddf2 100644 --- a/crates/openlogi-cli/src/cmd/diag/features.rs +++ b/crates/openlogi-cli/src/cmd/diag/features.rs @@ -13,10 +13,10 @@ use crate::cmd::diag::first_online_device; pub struct FeaturesArgs {} pub async fn run(_args: FeaturesArgs) -> Result<()> { - let (uid, slot, name) = first_online_device().await?; - println!("device: {name} (slot {slot}, receiver {uid})"); + let (route, name) = first_online_device().await?; + println!("device: {name} ({route})"); - let entries = openlogi_hid::dump_features(Some(&uid), slot) + let entries = openlogi_hid::dump_features(&route) .await .context("dump features")?; diff --git a/crates/openlogi-cli/src/cmd/diag/mod.rs b/crates/openlogi-cli/src/cmd/diag/mod.rs index b10e6d8..155ab16 100644 --- a/crates/openlogi-cli/src/cmd/diag/mod.rs +++ b/crates/openlogi-cli/src/cmd/diag/mod.rs @@ -8,6 +8,7 @@ use anyhow::Result; use clap::Subcommand; +use openlogi_hid::DeviceRoute; pub mod dpi; pub mod features; @@ -33,21 +34,31 @@ impl DiagCmd { } } -/// Shared device picker: enumerate inventories, return the first online -/// paired device with a receiver `unique_id` (i.e. the same selection rule -/// the GUI uses for its initial DPI target). -pub(crate) async fn first_online_device() -> Result<(String, u8, String)> { +/// Shared device picker: enumerate inventories, return the [`DeviceRoute`] + +/// display name of the first online paired device (the same selection rule the +/// GUI uses for its initial target). Builds a Bolt route when the device is +/// behind a receiver, a direct route otherwise (USB cable / Bluetooth). +pub(crate) async fn first_online_device() -> Result<(DeviceRoute, String)> { use anyhow::anyhow; let inventories = openlogi_hid::enumerate().await?; inventories .into_iter() .find_map(|inv| { - let uid = inv.receiver.unique_id?; let paired = inv.paired.into_iter().find(|p| p.online)?; + let route = match inv.receiver.unique_id { + Some(receiver_uid) => DeviceRoute::Bolt { + receiver_uid, + slot: paired.slot, + }, + None => DeviceRoute::Direct { + vendor_id: inv.receiver.vendor_id, + product_id: inv.receiver.product_id, + }, + }; let name = paired .codename .unwrap_or_else(|| format!("Slot {}", paired.slot)); - Some((uid, paired.slot, name)) + Some((route, name)) }) .ok_or_else(|| anyhow!("no online HID++ device found — is a Logi mouse paired?")) } diff --git a/crates/openlogi-cli/src/cmd/diag/smartshift.rs b/crates/openlogi-cli/src/cmd/diag/smartshift.rs index 63bdd0e..16d05bb 100644 --- a/crates/openlogi-cli/src/cmd/diag/smartshift.rs +++ b/crates/openlogi-cli/src/cmd/diag/smartshift.rs @@ -14,10 +14,10 @@ pub struct SmartshiftArgs { } pub async fn run(args: SmartshiftArgs) -> Result<()> { - let (uid, slot, name) = first_online_device().await?; - println!("device: {name} (slot {slot}, receiver {uid})"); + let (route, name) = first_online_device().await?; + println!("device: {name} ({route})"); - let before = openlogi_hid::get_smartshift_status(Some(&uid), slot) + let before = openlogi_hid::get_smartshift_status(&route) .await .context("read SmartShift status")?; println!( @@ -25,12 +25,12 @@ pub async fn run(args: SmartshiftArgs) -> Result<()> { before.mode, before.sensitivity ); - let new_mode = openlogi_hid::toggle_smartshift(Some(&uid), slot) + let new_mode = openlogi_hid::toggle_smartshift(&route) .await .context("toggle SmartShift")?; println!(" toggled to: {new_mode:?}"); - let after = openlogi_hid::get_smartshift_status(Some(&uid), slot) + let after = openlogi_hid::get_smartshift_status(&route) .await .context("read SmartShift after toggle")?; println!( @@ -51,7 +51,7 @@ pub async fn run(args: SmartshiftArgs) -> Result<()> { } println!(" restoring mode: {:?}", before.mode); - openlogi_hid::toggle_smartshift(Some(&uid), slot) + openlogi_hid::toggle_smartshift(&route) .await .context("restore SmartShift")?; diff --git a/crates/openlogi-gui/src/components/dpi_panel.rs b/crates/openlogi-gui/src/components/dpi_panel.rs index 8036049..505182a 100644 --- a/crates/openlogi-gui/src/components/dpi_panel.rs +++ b/crates/openlogi-gui/src/components/dpi_panel.rs @@ -79,7 +79,7 @@ impl DpiPanel { // when the panel was constructed. let target = cx .try_global::() - .and_then(|s| s.current_record().and_then(|r| r.dpi_target.clone())); + .and_then(|s| s.current_record().and_then(|r| r.route.clone())); write_dpi_in_background(None, target, dpi); } }, @@ -186,7 +186,7 @@ fn preset_chip(idx: usize, value: u32, active: bool, presets: &[u32], pal: Palet .on_click(move |_event, _window, cx| { let target = cx .try_global::() - .and_then(|s| s.current_record().and_then(|r| r.dpi_target.clone())); + .and_then(|s| s.current_record().and_then(|r| r.route.clone())); cx.update_global::(|state, _| state.dpi = value); write_dpi_in_background(None, target, value); cx.refresh_windows(); diff --git a/crates/openlogi-gui/src/hardware.rs b/crates/openlogi-gui/src/hardware.rs index 18704e7..98657ee 100644 --- a/crates/openlogi-gui/src/hardware.rs +++ b/crates/openlogi-gui/src/hardware.rs @@ -12,28 +12,21 @@ //! transient open is kept as a fallback for callers (e.g. the CGEventTap hook) //! firing while no session is connected. -use openlogi_hid::{CaptureChannel, SharedChannel}; +use openlogi_hid::{CaptureChannel, DeviceRoute, SharedChannel}; use tracing::{debug, warn}; -/// Clone out the capture session's channel when it points at `target`. `None` -/// when no slot is supplied (e.g. the DPI slider, where a transient open is -/// fine) or the open channel targets a different device. -fn reusable_channel(capture: Option<&CaptureChannel>, target: &DpiTarget) -> Option { +/// Clone out the capture session's channel when it reaches `route`. `None` when +/// no capture session is connected or the open channel points at a different +/// device. +fn reusable_channel( + capture: Option<&CaptureChannel>, + route: &DeviceRoute, +) -> Option { capture? .read() .ok() .and_then(|slot| (*slot).clone()) - .filter(|chan| chan.matches(Some(&target.receiver_uid), target.slot)) -} - -/// Identifies which physical device hardware-side writes should target. -/// `receiver_uid` is the Bolt receiver's unique id (so writes route -/// correctly when more than one receiver is plugged in); `slot` is the -/// device's pairing slot on that receiver. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DpiTarget { - pub receiver_uid: String, - pub slot: u8, + .filter(|chan| chan.matches(route)) } /// Spawn an OS thread that toggles SmartShift (free ↔ ratchet) on the @@ -42,7 +35,7 @@ pub struct DpiTarget { /// logged. pub fn toggle_smartshift_in_background( capture: Option<&CaptureChannel>, - target: Option, + target: Option, ) { let Some(target) = target else { debug!("no target device — SmartShift toggle skipped"); @@ -64,13 +57,12 @@ pub fn toggle_smartshift_in_background( let result = rt.block_on(async { match &shared { Some(shared) => openlogi_hid::toggle_smartshift_on(shared).await, - None => { - openlogi_hid::toggle_smartshift(Some(&target.receiver_uid), target.slot).await - } + None => openlogi_hid::toggle_smartshift(&target).await, } }); + let index = target.device_index(); match result { - Ok(mode) => debug!(slot = target.slot, ?mode, reused, "SmartShift toggled"), + Ok(mode) => debug!(index, ?mode, reused, "SmartShift toggled"), Err(e) => warn!(error = ?e, "SmartShift toggle failed"), } }); @@ -82,7 +74,7 @@ pub fn toggle_smartshift_in_background( /// `target == None` is a no-op (dev environment without a real device). pub fn write_dpi_in_background( capture: Option<&CaptureChannel>, - target: Option, + target: Option, dpi: u32, ) { let Some(target) = target else { @@ -109,14 +101,12 @@ pub fn write_dpi_in_background( let result = rt.block_on(async { match &shared { Some(shared) => openlogi_hid::set_dpi_on(shared, dpi_u16).await, - None => { - openlogi_hid::set_dpi(Some(&target.receiver_uid), target.slot, dpi_u16).await - } + None => openlogi_hid::set_dpi(&target, dpi_u16).await, } }); match result { Ok(()) => debug!( - slot = target.slot, + index = target.device_index(), dpi = dpi_u16, reused, "DPI written to device" diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 7fea1b9..a34a3b4 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -170,7 +170,7 @@ impl AppState { let presets = record .map(|r| config.dpi_presets(&r.config_key)) .unwrap_or_default(); - let target = record.and_then(|r| r.dpi_target.clone()); + let target = record.and_then(|r| r.route.clone()); ( bindings, gesture_bindings, @@ -517,7 +517,7 @@ impl AppState { .current_record() .map(|r| self.config.dpi_presets(&r.config_key)) .unwrap_or_default(); - let target = self.current_record().and_then(|r| r.dpi_target.clone()); + let target = self.current_record().and_then(|r| r.route.clone()); match self.dpi_cycle.write() { Ok(mut guard) => { *guard = DpiCycleState { diff --git a/crates/openlogi-gui/src/state/devices.rs b/crates/openlogi-gui/src/state/devices.rs index bc545d6..0925585 100644 --- a/crates/openlogi-gui/src/state/devices.rs +++ b/crates/openlogi-gui/src/state/devices.rs @@ -1,14 +1,14 @@ //! Device-list construction and selection helpers for [`super::AppState`]. use openlogi_core::device::{BatteryInfo, DeviceInventory, DeviceKind}; +use openlogi_hid::{DIRECT_DEVICE_INDEX, DeviceRoute}; use crate::asset::{AssetResolver, ResolvedAsset}; -use crate::hardware::DpiTarget; /// One paired device with everything the UI needs to switch to it in O(1): /// the config key (for bindings/DPI persistence), a display name, the /// resolved asset (PNG + metadata, or `None` for the synthetic fallback), -/// and the routing target for HID++ DPI writes. +/// and the [`DeviceRoute`] HID++ writes / capture target. /// /// The `kind` / `slot` / `online` / `battery` fields mirror the source /// [`PairedDevice`](openlogi_core::device::PairedDevice) so the header @@ -20,7 +20,7 @@ pub struct DeviceRecord { pub config_key: String, pub display_name: String, pub asset: Option, - pub dpi_target: Option, + pub route: Option, pub kind: DeviceKind, pub slot: u8, pub online: bool, @@ -33,7 +33,6 @@ pub(super) fn build_device_list( ) -> Vec { let mut list = Vec::new(); for inv in inventories { - let receiver_uid = inv.receiver.unique_id.clone(); for paired in &inv.paired { let Some(model) = paired.model_info.as_ref() else { continue; @@ -45,15 +44,11 @@ pub(super) fn build_device_list( .map(|a| a.display_name.clone()) .or_else(|| paired.codename.clone()) .unwrap_or_else(|| format!("Slot {}", paired.slot)); - let dpi_target = receiver_uid.as_ref().map(|uid| DpiTarget { - receiver_uid: uid.clone(), - slot: paired.slot, - }); list.push(DeviceRecord { config_key, display_name, asset, - dpi_target, + route: device_route(inv, paired.slot), kind: paired.kind, slot: paired.slot, online: paired.online, @@ -64,6 +59,28 @@ pub(super) fn build_device_list( list } +/// Build the [`DeviceRoute`] HID++ writes use to reach a device. +/// +/// A Bolt-paired device routes through its receiver UID + slot. A directly +/// attached one (USB cable / Bluetooth) carries no receiver UID and sits at +/// [`DIRECT_DEVICE_INDEX`] — it routes by the HID node's vendor/product id +/// instead. A Bolt device whose receiver UID couldn't be read gets no route +/// (`None`), so hardware writes are skipped rather than mis-routed to the +/// receiver's own pid. +fn device_route(inv: &DeviceInventory, slot: u8) -> Option { + match &inv.receiver.unique_id { + Some(receiver_uid) => Some(DeviceRoute::Bolt { + receiver_uid: receiver_uid.clone(), + slot, + }), + None if slot == DIRECT_DEVICE_INDEX => Some(DeviceRoute::Direct { + vendor_id: inv.receiver.vendor_id, + product_id: inv.receiver.product_id, + }), + None => None, + } +} + pub(super) fn pick_initial_device(list: &[DeviceRecord], saved: Option<&str>) -> usize { saved .and_then(|key| list.iter().position(|r| r.config_key == key)) diff --git a/crates/openlogi-gui/src/state/dpi.rs b/crates/openlogi-gui/src/state/dpi.rs index bcae226..c29f405 100644 --- a/crates/openlogi-gui/src/state/dpi.rs +++ b/crates/openlogi-gui/src/state/dpi.rs @@ -1,6 +1,6 @@ //! DPI-cycle state shared with background action dispatch. -use crate::hardware::DpiTarget; +use openlogi_hid::DeviceRoute; /// Shared state consumed by the OS hook thread and the DPI panel UI to /// implement DPI preset cycling and direct preset selection actions. @@ -11,14 +11,14 @@ use crate::hardware::DpiTarget; pub struct DpiCycleState { pub presets: Vec, pub index: usize, - pub target: Option, + pub target: Option, } impl DpiCycleState { /// Advance to the next preset (wrapping last → first) and return the new /// DPI + the device target to write to. Returns `None` if `presets` is /// empty. - pub fn cycle(&mut self) -> Option<(u32, Option)> { + pub fn cycle(&mut self) -> Option<(u32, Option)> { if self.presets.is_empty() { return None; } @@ -28,7 +28,7 @@ impl DpiCycleState { /// Jump to preset `i`, clamping to the list length. Returns the DPI + /// target, or `None` if `presets` is empty. - pub fn set(&mut self, i: usize) -> Option<(u32, Option)> { + pub fn set(&mut self, i: usize) -> Option<(u32, Option)> { if self.presets.is_empty() { return None; } diff --git a/crates/openlogi-gui/src/watchers/gesture.rs b/crates/openlogi-gui/src/watchers/gesture.rs index cd3d80c..f500a45 100644 --- a/crates/openlogi-gui/src/watchers/gesture.rs +++ b/crates/openlogi-gui/src/watchers/gesture.rs @@ -21,11 +21,10 @@ use std::thread; use std::time::Duration; use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding}; -use openlogi_hid::{CaptureChannel, CapturedInput, GestureTarget, run_capture_session}; +use openlogi_hid::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_session}; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, warn}; -use crate::hardware::DpiTarget; use crate::hook_runtime::{self, BindingMap}; use crate::state::DpiCycleState; @@ -87,7 +86,7 @@ async fn manage( capture_channel: CaptureChannel, ) { let (tx, mut rx) = mpsc::unbounded_channel::(); - let mut current: Option<(DpiTarget, bool)> = None; + let mut current: Option<(DeviceRoute, bool)> = None; let mut stop: Option> = None; let mut ticker = tokio::time::interval(TARGET_POLL); @@ -109,23 +108,13 @@ async fn manage( let _ = stop.send(()); } current.clone_from(&want); - if let Some((target, capture_thumbwheel)) = want { + if let Some((route, capture_thumbwheel)) = want { let (stop_tx, stop_rx) = oneshot::channel(); let sink = tx.clone(); - let capture_target = GestureTarget { - receiver_uid: Some(target.receiver_uid), - slot: target.slot, - }; let slot = Arc::clone(&capture_channel); tokio::spawn(async move { - if let Err(e) = run_capture_session( - capture_target, - capture_thumbwheel, - sink, - stop_rx, - slot, - ) - .await + if let Err(e) = + run_capture_session(route, capture_thumbwheel, sink, stop_rx, slot).await { debug!(error = %e, "capture session ended"); } diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index e389ebd..e8d3900 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -18,20 +18,15 @@ use std::sync::{Arc, Mutex, PoisonError, RwLock}; use std::time::{Duration, Instant}; -use hidpp::{ - channel::HidppChannel, - device::Device, - protocol::v20, - receiver::{self, Receiver}, -}; +use hidpp::{channel::HidppChannel, device::Device, protocol::v20}; use openlogi_core::binding::{ButtonId, GestureDirection, detect_swipe}; use thiserror::Error; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4}; +use crate::route::{DeviceRoute, open_route_channel}; use crate::thumbwheel::{self, Thumbwheel}; -use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel}; use crate::write::SharedChannel; /// Shared slot holding the active capture session's open channel, so DPI / @@ -39,16 +34,6 @@ use crate::write::SharedChannel; /// whenever no session is connected. pub type CaptureChannel = Arc>>; -/// Which device to capture from. Mirrors how DPI / SmartShift writes target a -/// device: an optional Bolt receiver UID plus a pairing slot. -#[derive(Debug, Clone)] -pub struct GestureTarget { - /// Bolt receiver unique ID, or `None` to use the first Bolt receiver found. - pub receiver_uid: Option, - /// Pairing slot of the device on that receiver. - pub slot: u8, -} - /// One input captured from the active device. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CapturedInput { @@ -70,11 +55,11 @@ pub enum GestureError { /// HID transport-level failure while enumerating or opening the device. #[error("HID transport error")] Hid(#[from] async_hid::HidError), - /// No Bolt receiver matched the target's `receiver_uid`. - #[error("no matching receiver for the capture target")] - ReceiverNotFound, - /// The device on the target slot did not answer HID++. - #[error("device on slot {0} did not respond to HID++")] + /// No connected device matched the capture route. + #[error("no connected device matched the capture route")] + DeviceNotFound, + /// The device at the target index did not answer HID++. + #[error("device at index {0:#04x} did not respond to HID++")] DeviceUnreachable(u8), /// A HID++ feature call returned an error; inner string carries context. #[error("HID++ protocol error: {0}")] @@ -105,7 +90,7 @@ struct CaptureAccum { } /// Capture the gesture button, DPI/ModeShift button, and (when -/// `capture_thumbwheel`) the thumb wheel on `target` until `shutdown` resolves, +/// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves, /// forwarding each event to `sink`. /// /// Opens and holds one HID++ channel, diverts whichever of those controls the @@ -113,30 +98,28 @@ struct CaptureAccum { /// dropped), after restoring every diverted control. Setup errors are returned; /// failures to restore on the way out are logged, not propagated. pub async fn run_capture_session( - target: GestureTarget, + route: DeviceRoute, capture_thumbwheel: bool, sink: mpsc::UnboundedSender, shutdown: oneshot::Receiver<()>, channel_slot: CaptureChannel, ) -> Result<(), GestureError> { - let chan = open_target_channel(&target).await?; - let armed = arm_controls(&chan, target.slot, capture_thumbwheel).await?; + let chan = open_route_channel(&route) + .await? + .ok_or(GestureError::DeviceNotFound)?; + let device_index = route.device_index(); + let armed = arm_controls(&chan, device_index, capture_thumbwheel).await?; // Publish this device's open channel so DPI/SmartShift writes reuse it // instead of opening their own. Cleared on the way out. if let Ok(mut slot) = channel_slot.write() { - *slot = Some(SharedChannel::new( - Arc::clone(&chan), - target.receiver_uid.clone(), - target.slot, - )); + *slot = Some(SharedChannel::new(Arc::clone(&chan), route.clone())); } let accum = Arc::new(Mutex::new(CaptureAccum::default())); let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx); let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx); let dpi_set = armed.dpi_cids.clone(); - let device_index = target.slot; let hdl = chan.add_msg_listener({ let accum = Arc::clone(&accum); let sink = sink.clone(); @@ -168,7 +151,7 @@ pub async fn run_capture_session( }); info!( - slot = target.slot, + index = device_index, gesture = armed.gesture_diverted, dpi_buttons = armed.dpi_cids.len(), thumbwheel = armed.thumb.is_some(), @@ -181,7 +164,7 @@ pub async fn run_capture_session( *slot = None; } armed.disarm().await; - debug!(slot = target.slot, "control capture stopped"); + debug!(index = device_index, "control capture stopped"); Ok(()) } @@ -388,30 +371,6 @@ fn handle_reprog( } } } - -/// Open and return a HID++ channel for `target`, matching the Bolt receiver by -/// UID when one is given. Mirrors `write::with_device`'s selection, but keeps -/// the channel open instead of running a closure and dropping it. -async fn open_target_channel(target: &GestureTarget) -> Result, GestureError> { - let candidates = enumerate_hidpp_devices().await?; - for dev in candidates { - let Some((_, channel)) = open_hidpp_channel(dev).await? else { - continue; - }; - let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(&channel)) else { - continue; - }; - if let Some(want) = target.receiver_uid.as_deref() { - match bolt.get_unique_id().await { - Ok(uid) if uid.eq_ignore_ascii_case(want) => {} - _ => continue, - } - } - return Ok(channel); - } - Err(GestureError::ReceiverNotFound) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/openlogi-hid/src/inventory.rs b/crates/openlogi-hid/src/inventory.rs index b306d65..0e5ae4e 100644 --- a/crates/openlogi-hid/src/inventory.rs +++ b/crates/openlogi-hid/src/inventory.rs @@ -26,6 +26,7 @@ use thiserror::Error; use tokio::time::timeout; use tracing::{debug, warn}; +use crate::route::DIRECT_DEVICE_INDEX; use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel}; /// How long to wait for device-arrival event bursts before assuming the @@ -173,23 +174,29 @@ async fn probe_direct( channel: Arc, info: &async_hid::DeviceInfo, ) -> Option { - const DIRECT_DEVICE_INDEX: u8 = 0xff; - let (battery, model_info) = probe_features(&channel, DIRECT_DEVICE_INDEX).await; - // Require BatteryInfo before treating this as a direct-paired device. - // Bolt receivers also answer HID++ at slot 0xff with DeviceInformation - // (their own metadata) but do not expose UnifiedBattery — battery - // therefore distinguishes a peripheral from a receiver's secondary - // HID interface. Without this guard, a Bolt setup ends up with two - // entries in `device_list`: the real mouse (via the Bolt path) and a - // fake "direct device" pointing at the receiver, which sits at - // index 0 and steals every DPI / SmartShift write attempt. - if battery.is_none() { + // Hybrid peripheral discriminator. A genuine directly-attached device is + // either wireless/Bluetooth — which reports a battery — or wired, which + // reports none but still exposes a control feature (adjustable DPI or + // reprogrammable buttons). A Bolt receiver's secondary HID interface also + // answers DeviceInformation at 0xff, but exposes neither battery nor those + // control features, so it's filtered out here. Without this guard a Bolt + // setup ends up with two entries in `device_list`: the real mouse (via the + // Bolt path) and a phantom "direct device" pointing at the receiver, which + // sits at index 0 and steals every DPI / SmartShift write attempt. + // + // Battery is the fast path (no extra round-trips); the feature probe only + // runs for battery-less devices, so wired mice cost one more lookup while + // the common wireless case is unaffected. + let is_peripheral = + battery.is_some() || exposes_peripheral_feature(&channel, DIRECT_DEVICE_INDEX).await; + if !is_peripheral { debug!( vid = format_args!("{:04x}", info.vendor_id), pid = format_args!("{:04x}", info.product_id), has_model = model_info.is_some(), - "no battery at slot 0xff — likely a receiver secondary interface; skipping" + "slot 0xff exposes no battery or control feature — likely a receiver \ + secondary interface; skipping" ); return None; } @@ -314,6 +321,38 @@ async fn probe_features( (battery, model_info) } +/// HID++ feature IDs that mark a device as a controllable peripheral rather +/// than a bare receiver interface: adjustable DPI (both encodings) and +/// reprogrammable controls. Used by [`probe_direct`]'s hybrid discriminator +/// to admit wired mice, which report no battery. +const PERIPHERAL_FEATURE_IDS: [u16; 3] = [ + 0x2201, // AdjustableDpi + 0x2202, // ExtendedAdjustableDpi + 0x1b04, // ReprogControlsV4 +]; + +/// Whether the device at `index` announces any [`PERIPHERAL_FEATURE_IDS`]. +/// Looks each up through the device root — hidpp 0.2's feature registry +/// doesn't carry these, so `enumerate_features` wouldn't surface them (see +/// `write::open_feature`). +async fn exposes_peripheral_feature(channel: &Arc, index: u8) -> bool { + let device = match Device::new(Arc::clone(channel), index).await { + Ok(d) => d, + Err(e) => { + debug!(index, error = ?e, "Device::new failed during peripheral probe"); + return false; + } + }; + for id in PERIPHERAL_FEATURE_IDS { + match device.root().get_feature(id).await { + Ok(Some(_)) => return true, + Ok(None) => {} + Err(e) => debug!(index, id, error = ?e, "root feature probe failed"), + } + } + false +} + fn map_kind(k: BoltDeviceKind) -> DeviceKind { match k { BoltDeviceKind::Keyboard => DeviceKind::Keyboard, diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs index 0ac598a..4dd25a4 100644 --- a/crates/openlogi-hid/src/lib.rs +++ b/crates/openlogi-hid/src/lib.rs @@ -6,6 +6,7 @@ //! - [`enumerate`] — one-shot inventory of receivers + paired devices. //! - [`set_dpi`] — write a new sensor DPI to a connected device. +mod route; mod transport; pub mod adjustable_dpi; @@ -17,14 +18,13 @@ pub mod smartshift; pub mod thumbwheel; pub mod write; -pub use gesture::{ - CaptureChannel, CapturedInput, GestureError, GestureTarget, run_capture_session, -}; +pub use gesture::{CaptureChannel, CapturedInput, GestureError, run_capture_session}; pub use inventory::{InventoryError, enumerate}; pub use pairing::{ Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver, PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair, }; +pub use route::{DIRECT_DEVICE_INDEX, DeviceRoute}; pub use smartshift::{SmartShiftMode, SmartShiftStatus}; pub use write::{ FeatureEntry, SharedChannel, WriteError, dump_features, get_dpi, get_smartshift_status, diff --git a/crates/openlogi-hid/src/route.rs b/crates/openlogi-hid/src/route.rs new file mode 100644 index 0000000..6628997 --- /dev/null +++ b/crates/openlogi-hid/src/route.rs @@ -0,0 +1,112 @@ +//! How to reach a controllable HID++ device, and the logic to (re-)open its +//! channel. +//! +//! Two addressing modes: +//! +//! - [`DeviceRoute::Bolt`] — a device paired to a Logi Bolt receiver, reached +//! through the receiver channel at a pairing slot. +//! - [`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`]. +//! +//! Both the write path ([`crate::write`]) and the capture session +//! ([`crate::gesture`]) resolve a route to an open channel through +//! [`open_route_channel`], so the Bolt-vs-direct branch lives in exactly one +//! place. + +use std::fmt; +use std::sync::Arc; + +use hidpp::{ + channel::HidppChannel, + receiver::{self, Receiver}, +}; + +use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel}; + +/// HID++ device index that addresses a directly-attached device's own +/// features (USB-cable or Bluetooth, no receiver indirection). +pub const DIRECT_DEVICE_INDEX: u8 = 0xff; + +/// How to reach a controllable HID++ device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceRoute { + /// Paired to a Logi Bolt receiver. `receiver_uid` disambiguates multiple + /// plugged-in receivers; `slot` is the device's pairing slot (1..=6). + Bolt { receiver_uid: String, slot: u8 }, + /// Attached straight to the host over USB cable or Bluetooth, addressed at + /// the HID++ self-index. Re-found by matching the HID node's vendor/product + /// id — two identical mice on one host are indistinguishable here, so the + /// first match wins (acceptable for v0). + Direct { vendor_id: u16, product_id: u16 }, +} + +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. + #[must_use] + pub fn device_index(&self) -> u8 { + match self { + Self::Bolt { slot, .. } => *slot, + Self::Direct { .. } => DIRECT_DEVICE_INDEX, + } + } +} + +impl fmt::Display for DeviceRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bolt { receiver_uid, slot } => { + write!(f, "slot {slot} on receiver {receiver_uid}") + } + Self::Direct { + vendor_id, + product_id, + } => write!(f, "direct {vendor_id:04x}:{product_id:04x}"), + } + } +} + +/// Enumerate HID++ candidates and open the channel that reaches `route`. +/// +/// For a Bolt route this is the receiver channel (the caller addresses the +/// device through its slot via [`DeviceRoute::device_index`]); for a direct +/// route it is the device's own channel. Returns `None` when nothing matching +/// is currently connected. +pub(crate) async fn open_route_channel( + route: &DeviceRoute, +) -> Result>, async_hid::HidError> { + let candidates = enumerate_hidpp_devices().await?; + for dev in candidates { + // A direct route's vendor/product id is on the unopened `DeviceInfo` + // (`async_hid::Device` derefs to it), so skip non-matching nodes before + // paying the ~100ms channel-open cost — otherwise every direct write on + // a host that also has a Bolt receiver opens the receiver's channel + // first. The Bolt branch still needs an open channel for `detect`. + if let DeviceRoute::Direct { + vendor_id, + product_id, + } = route + && (dev.vendor_id != *vendor_id || dev.product_id != *product_id) + { + continue; + } + let Some((_, channel)) = open_hidpp_channel(dev).await? else { + continue; + }; + match route { + DeviceRoute::Bolt { receiver_uid, .. } => { + let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(&channel)) else { + continue; + }; + if let Ok(uid) = bolt.get_unique_id().await + && uid.eq_ignore_ascii_case(receiver_uid) + { + return Ok(Some(channel)); + } + } + DeviceRoute::Direct { .. } => return Ok(Some(channel)), + } + } + Ok(None) +} diff --git a/crates/openlogi-hid/src/write.rs b/crates/openlogi-hid/src/write.rs index 8a36f1c..374bd57 100644 --- a/crates/openlogi-hid/src/write.rs +++ b/crates/openlogi-hid/src/write.rs @@ -1,45 +1,36 @@ -//! HID++ writes back to the device — currently just sensor DPI. +//! HID++ writes back to the device — DPI and SmartShift. //! -//! Mirrors [`crate::inventory`]'s channel-opening dance, scoped to a -//! single receiver + slot. Each call re-enumerates and re-opens — fine -//! at the frequency this is invoked (once per slider release). +//! Each entry point takes a [`DeviceRoute`] and resolves it to an open channel +//! through [`open_route_channel`], so the same call works whether the device is +//! behind a Bolt receiver or attached directly (USB cable / Bluetooth). Each +//! call re-enumerates and re-opens — fine at the frequency this is invoked +//! (once per slider release) — unless a [`SharedChannel`] from the capture +//! session is reused. use std::sync::Arc; -use hidpp::{ - channel::HidppChannel, - device::Device, - feature::CreatableFeature, - receiver::{self, Receiver}, -}; +use hidpp::{channel::HidppChannel, device::Device, feature::CreatableFeature}; use thiserror::Error; use tracing::debug; use crate::adjustable_dpi::AdjustableDpiFeatureV0; +use crate::route::{DeviceRoute, open_route_channel}; use crate::smartshift::{SmartShiftFeatureV0, SmartShiftMode, SmartShiftStatus}; -use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel}; #[derive(Debug, Error)] pub enum WriteError { #[error("HID transport error")] Hid(#[from] async_hid::HidError), - #[error("no matching Bolt receiver found")] - ReceiverNotFound, - #[error("device on slot {slot} did not respond to HID++")] - DeviceUnreachable { slot: u8 }, + #[error("no connected device matched the route")] + DeviceNotFound, + #[error("device at index {index:#04x} did not respond to HID++")] + DeviceUnreachable { index: u8 }, #[error("device does not expose HID++ feature {feature_hex:#06x}")] FeatureUnsupported { feature_hex: u16 }, #[error("HID++ protocol error: {0}")] Hidpp(String), } -/// Push a new DPI value to the sensor on `slot` of the receiver -/// identified by `receiver_uid`. Pass `None` to target the first Bolt -/// receiver found. -/// -/// Re-enumerates each call — opening a HID++ channel is cheap enough -/// at slider-release cadence, and avoids the complexity of holding a -/// long-lived session over GPUI's runtime. /// Snapshot of one HID++ feature exposed by a device: protocol ID + /// version. Returned by [`dump_features`] for diagnostics. #[derive(Debug, Clone, Copy)] @@ -48,19 +39,17 @@ pub struct FeatureEntry { pub version: u8, } -/// Enumerate every HID++ feature the device at `slot` reports — used by +/// Enumerate every HID++ feature the device on `route` reports — used by /// `openlogi diag features` to confirm which DPI / SmartShift / etc. /// feature IDs a given peripheral actually exposes (e.g. some mice use /// `0x2202 ExtendedAdjustableDpi` instead of `0x2201 AdjustableDpi`). -pub async fn dump_features( - receiver_uid: Option<&str>, - slot: u8, -) -> Result, WriteError> { +pub async fn dump_features(route: &DeviceRoute) -> Result, WriteError> { use hidpp::feature::feature_set::v0::FeatureSetFeatureV0; - with_device(receiver_uid, slot, |channel| async move { - let mut device = Device::new(Arc::clone(&channel), slot) + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(Arc::clone(&channel), index) .await - .map_err(|_| WriteError::DeviceUnreachable { slot })?; + .map_err(|_| WriteError::DeviceUnreachable { index })?; // The root feature exposes the FeatureSet (0x0001) at a fixed // address; we look it up directly rather than going through // `enumerate_features` so the iteration is observable. @@ -106,7 +95,6 @@ pub async fn dump_features( /// `add_feature` then attaches our wrapper to that index. async fn open_feature( device: &mut Device, - _slot: u8, ) -> Result, WriteError> { let info = device .root() @@ -120,12 +108,13 @@ async fn open_feature( /// Read the device's current DPI on sensor 0 — companion to [`set_dpi`]. /// Used by `openlogi diag dpi` and any future Settings → Diagnostics /// surface that wants to display the current value without writing. -pub async fn get_dpi(receiver_uid: Option<&str>, slot: u8) -> Result { - with_device(receiver_uid, slot, |channel| async move { - let mut device = Device::new(Arc::clone(&channel), slot) +pub async fn get_dpi(route: &DeviceRoute) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(Arc::clone(&channel), index) .await - .map_err(|_| WriteError::DeviceUnreachable { slot })?; - let feature = open_feature::(&mut device, slot).await?; + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; feature .get_sensor_dpi(0) .await @@ -136,15 +125,13 @@ pub async fn get_dpi(receiver_uid: Option<&str>, slot: u8) -> Result, - slot: u8, -) -> Result { - with_device(receiver_uid, slot, |channel| async move { - let mut device = Device::new(Arc::clone(&channel), slot) +pub async fn get_smartshift_status(route: &DeviceRoute) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + let mut device = Device::new(Arc::clone(&channel), index) .await - .map_err(|_| WriteError::DeviceUnreachable { slot })?; - let feature = open_feature::(&mut device, slot).await?; + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; feature .get_status() .await @@ -153,24 +140,26 @@ pub async fn get_smartshift_status( .await } -pub async fn set_dpi(receiver_uid: Option<&str>, slot: u8, dpi: u16) -> Result<(), WriteError> { - with_device(receiver_uid, slot, |channel| async move { - set_dpi_on_channel(&channel, slot, dpi).await +pub async fn set_dpi(route: &DeviceRoute, dpi: u16) -> Result<(), WriteError> { + let index = route.device_index(); + with_route(route, move |channel| async move { + set_dpi_on_channel(&channel, index, dpi).await }) .await } -/// The DPI write itself, on an already-open channel. Shared by [`set_dpi`] -/// (which opens a fresh channel) and [`set_dpi_on`] (which reuses one). +/// The DPI write itself, on an already-open channel at HID++ `index`. Shared by +/// [`set_dpi`] (which opens a fresh channel) and [`set_dpi_on`] (which reuses +/// one). async fn set_dpi_on_channel( channel: &Arc, - slot: u8, + index: u8, dpi: u16, ) -> Result<(), WriteError> { - let mut device = Device::new(Arc::clone(channel), slot) + let mut device = Device::new(Arc::clone(channel), index) .await - .map_err(|_| WriteError::DeviceUnreachable { slot })?; - let feature = open_feature::(&mut device, slot).await?; + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; feature .set_sensor_dpi(0, dpi) .await @@ -182,10 +171,10 @@ async fn set_dpi_on_channel( // the device. if let Ok(actual) = feature.get_sensor_dpi(0).await { if actual == dpi { - debug!(slot, dpi, "wrote DPI (verified)"); + debug!(index, dpi, "wrote DPI (verified)"); } else { tracing::warn!( - slot, + index, requested = dpi, actual, "DPI write accepted but device reports a different value — \ @@ -193,37 +182,35 @@ async fn set_dpi_on_channel( ); } } else { - debug!(slot, dpi, "wrote DPI (read-back skipped)"); + debug!(index, dpi, "wrote DPI (read-back skipped)"); } Ok(()) } -/// Toggle SmartShift mode (free ↔ ratchet) on `slot`. Reads the current +/// Toggle SmartShift mode (free ↔ ratchet) on `route`. Reads the current /// mode first, then writes the opposite — keeps current sensitivity. /// Returns the new mode written. /// /// `FeatureUnsupported` when the device doesn't expose HID++ `0x2111` /// (older Logi mice and most non-MX devices). -pub async fn toggle_smartshift( - receiver_uid: Option<&str>, - slot: u8, -) -> Result { - with_device(receiver_uid, slot, |channel| async move { - toggle_smartshift_on_channel(&channel, slot).await +pub async fn toggle_smartshift(route: &DeviceRoute) -> Result { + let index = route.device_index(); + with_route(route, move |channel| async move { + toggle_smartshift_on_channel(&channel, index).await }) .await } -/// The SmartShift toggle itself, on an already-open channel. Shared by -/// [`toggle_smartshift`] and [`toggle_smartshift_on`]. +/// The SmartShift toggle itself, on an already-open channel at HID++ `index`. +/// Shared by [`toggle_smartshift`] and [`toggle_smartshift_on`]. async fn toggle_smartshift_on_channel( channel: &Arc, - slot: u8, + index: u8, ) -> Result { - let mut device = Device::new(Arc::clone(channel), slot) + let mut device = Device::new(Arc::clone(channel), index) .await - .map_err(|_| WriteError::DeviceUnreachable { slot })?; - let feature = open_feature::(&mut device, slot).await?; + .map_err(|_| WriteError::DeviceUnreachable { index })?; + let feature = open_feature::(&mut device).await?; let SmartShiftStatus { mode, sensitivity } = feature .get_status() .await @@ -233,89 +220,58 @@ async fn toggle_smartshift_on_channel( .set_status(next, sensitivity) .await .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?; - debug!(slot, ?next, "wrote SmartShift mode"); + debug!(index, ?next, "wrote SmartShift mode"); Ok(next) } -/// An open HID++ channel to a paired device, shared so DPI / SmartShift writes -/// can reuse the capture session's connection instead of re-enumerating and +/// An open HID++ channel to a device, shared so DPI / SmartShift writes can +/// reuse the capture session's connection instead of re-enumerating and /// opening a fresh channel each time (which costs ~100ms+). /// -/// Cheap to clone (an `Arc` plus the routing identity). Built by the capture -/// session via [`SharedChannel::new`] and stashed in a slot the GUI's write -/// path consults. +/// Cheap to clone (an `Arc` plus the [`DeviceRoute`] it points at). Built by +/// the capture session via [`SharedChannel::new`] and stashed in a slot the +/// GUI's write path consults. #[derive(Clone)] pub struct SharedChannel { channel: Arc, - receiver_uid: Option, - slot: u8, + route: DeviceRoute, } impl SharedChannel { - /// Wrap an open channel routed to `(receiver_uid, slot)`. + /// Wrap an open channel that reaches `route`. #[must_use] - pub(crate) fn new(channel: Arc, receiver_uid: Option, slot: u8) -> Self { - Self { - channel, - receiver_uid, - slot, - } + pub(crate) fn new(channel: Arc, route: DeviceRoute) -> Self { + Self { channel, route } } - /// Whether this channel targets `(receiver_uid, slot)` — so the write path - /// only reuses it for the device it actually points at. + /// Whether this channel reaches `route` — so the write path only reuses it + /// for the device it actually points at. #[must_use] - pub fn matches(&self, receiver_uid: Option<&str>, slot: u8) -> bool { - self.slot == slot - && match (self.receiver_uid.as_deref(), receiver_uid) { - (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), - (None, None) => true, - _ => false, - } + pub fn matches(&self, route: &DeviceRoute) -> bool { + self.route == *route } } /// Write DPI on an already-open [`SharedChannel`] — the fast path that skips /// enumeration and channel setup. pub async fn set_dpi_on(shared: &SharedChannel, dpi: u16) -> Result<(), WriteError> { - set_dpi_on_channel(&shared.channel, shared.slot, dpi).await + set_dpi_on_channel(&shared.channel, shared.route.device_index(), dpi).await } /// Toggle SmartShift on an already-open [`SharedChannel`]. pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result { - toggle_smartshift_on_channel(&shared.channel, shared.slot).await + toggle_smartshift_on_channel(&shared.channel, shared.route.device_index()).await } -/// Boilerplate-eater: enumerate HID candidates, find a matching Bolt -/// receiver, run `f` once with the opened HID++ channel. -async fn with_device( - receiver_uid: Option<&str>, - _slot: u8, - f: F, -) -> Result +/// Boilerplate-eater: open the channel that reaches `route`, then run `f` once +/// with it. The caller addresses features at [`DeviceRoute::device_index`]. +async fn with_route(route: &DeviceRoute, f: F) -> Result where F: FnOnce(Arc) -> Fut, Fut: std::future::Future>, { - let candidates = enumerate_hidpp_devices().await?; - - for dev in candidates { - let Some((_, channel)) = open_hidpp_channel(dev).await? else { - continue; - }; - let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(&channel)) else { - continue; - }; - - if let Some(want) = receiver_uid { - match bolt.get_unique_id().await { - Ok(uid) if uid.eq_ignore_ascii_case(want) => {} - _ => continue, - } - } - - return f(channel).await; + match open_route_channel(route).await? { + Some(channel) => f(channel).await, + None => Err(WriteError::DeviceNotFound), } - - Err(WriteError::ReceiverNotFound) }