use std::sync::{Arc, Mutex, PoisonError, RwLock};
use hidpp::{channel::HidppChannel, device::Device, protocol::v20};
use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
use serde::{Deserialize, Serialize};
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::write::SharedChannel;
const LIVENESS_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
const LIVENESS_PING_STRIKES: u8 = 2;
pub type CaptureChannel = Arc<RwLock<Option<SharedChannel>>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureStop {
Graceful,
Revoked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CapturedInput {
Gesture(ButtonId, GestureDirection),
ButtonPressed(ButtonId, #[serde(skip)] Option<i32>),
Scroll(i16),
}
#[derive(Debug, Error)]
pub enum GestureError {
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
#[error("no connected device matched the capture route")]
DeviceNotFound,
#[error("device at index {0:#04x} did not respond to HID++")]
DeviceUnreachable(u8),
#[error("HID++ protocol error: {0}")]
Hidpp(String),
}
#[derive(Default)]
struct CaptureAccum {
swipe: SwipeAccumulator,
gesture_source: Option<(u16, ButtonId)>,
overlap: bool,
gestures_down: Vec<u16>,
skip_first_raw_xy: bool,
dpi_down: bool,
buttons_down: Vec<u16>,
}
pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 3] = [
(0x0052, ButtonId::MiddleClick),
(0x0053, ButtonId::Back),
(0x0056, ButtonId::Forward),
];
pub const GESTURE_SOURCE_BUTTONS: [(u16, ButtonId); 2] = [
(reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton),
(reprog_controls::HAPTIC_PANEL_CID, ButtonId::HapticPanel),
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CaptureSpec {
pub capture_thumbwheel: bool,
pub divert_gesture_sources: Vec<u16>,
pub divert_buttons: Vec<(u16, ButtonId)>,
}
pub async fn run_capture_session(
route: DeviceRoute,
spec: CaptureSpec,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<()>,
channel_slot: CaptureChannel,
) -> Result<(), GestureError> {
let chan = open_route_channel(&route)
.await?
.ok_or(GestureError::DeviceNotFound)?;
let device_index = route.device_index();
let armed = arm_controls(&chan, device_index, &spec).await?;
if let Ok(mut slot) = channel_slot.write() {
*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 gesture_cids = armed.gesture_cids.clone();
let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx);
let dpi_set = armed.dpi_cids.clone();
let button_set = armed.button_cids.clone();
let listener = chan.add_msg_listener_guarded({
let accum = Arc::clone(&accum);
let sink = sink.clone();
move |raw, matched| {
if matched {
return;
}
let msg = v20::Message::from(raw);
if let Some(idx) = reprog_index
&& let Some(event) = reprog_controls::decode_event(&msg, device_index, idx)
{
let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
handle_reprog(&mut acc, event, &gesture_cids, &dpi_set, &button_set, &sink);
return;
}
if let Some(idx) = thumb_index
&& let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
{
if event.single_tap {
let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel, None));
}
if event.rotation != 0 {
let _ = sink.send(CapturedInput::Scroll(event.rotation));
}
}
}
});
info!(
index = device_index,
gesture_sources = armed.gesture_cids.len(),
dpi_buttons = armed.dpi_cids.len(),
buttons = armed.button_cids.len(),
thumbwheel = armed.thumb.is_some(),
"control capture active"
);
let root = <hidpp::feature::root::RootFeature as hidpp::feature::CreatableFeature>::new(
Arc::clone(&chan),
device_index,
0,
);
let mut shutdown = std::pin::pin!(shutdown);
let mut silent_pings = 0u8;
let channel_dead = loop {
tokio::select! {
_ = &mut shutdown => break false,
() = tokio::time::sleep(LIVENESS_PING_INTERVAL) => {
match root.ping(0x5a).await {
Err(v20::Hidpp20Error::Channel(
hidpp::channel::ChannelError::Timeout
| hidpp::channel::ChannelError::NoResponse,
)) => {
silent_pings = silent_pings.saturating_add(1);
if silent_pings >= LIVENESS_PING_STRIKES {
warn!(
index = device_index,
"capture channel stopped delivering — restarting session on a fresh channel"
);
break true;
}
}
_ => silent_pings = 0,
}
}
}
};
drop(listener);
if let Ok(mut slot) = channel_slot.write()
&& slot
.as_ref()
.is_some_and(|shared| Arc::ptr_eq(shared.channel(), &chan))
{
*slot = None;
}
if channel_dead {
debug!(index = device_index, "skipping disarm on a dead channel");
} else {
armed.disarm().await;
}
debug!(index = device_index, "control capture stopped");
Ok(())
}
pub async fn run_capture_session_with_stop_reason(
route: DeviceRoute,
capture_thumbwheel: bool,
divert_gesture_button: bool,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<CaptureStop>,
channel_slot: CaptureChannel,
) -> Result<(), GestureError> {
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
let _ = shutdown.await;
let _ = tx.send(());
});
let spec = CaptureSpec {
capture_thumbwheel,
divert_gesture_sources: divert_gesture_button
.then_some(reprog_controls::GESTURE_BUTTON_CID)
.into_iter()
.collect(),
divert_buttons: Vec::new(),
};
run_capture_session(route, spec, sink, rx, channel_slot).await
}
pub async fn run_capture_session_with_registry(
route: DeviceRoute,
capture_thumbwheel: bool,
divert_gesture_button: bool,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<CaptureStop>,
channel_slot: CaptureChannel,
_registry: &crate::ChannelRegistry,
) -> Result<(), GestureError> {
run_capture_session_with_stop_reason(
route,
capture_thumbwheel,
divert_gesture_button,
sink,
shutdown,
channel_slot,
)
.await
}
#[derive(Default)]
struct ArmedControls {
reprog: Option<(ReprogControlsV4, u8)>,
gesture_cids: Vec<u16>,
dpi_cids: Vec<u16>,
button_cids: Vec<(u16, ButtonId)>,
reporting: Vec<ArmedCid>,
thumb: Option<(Thumbwheel, u8)>,
}
#[derive(Clone, Copy)]
struct ArmedCid {
cid: u16,
original: reprog_controls::CidReporting,
}
impl ArmedControls {
async fn disarm(&self) {
if let Some((rc, _)) = self.reprog.as_ref() {
for &reporting in &self.reporting {
restore_reporting(rc, reporting, "captured control").await;
}
}
if let Some((tw, _)) = self.thumb.as_ref() {
restore(tw.set_reporting(false, false).await, "thumb wheel");
}
}
}
async fn arm_controls(
chan: &Arc<HidppChannel>,
slot: u8,
spec: &CaptureSpec,
) -> Result<ArmedControls, GestureError> {
let device = Device::new(Arc::clone(chan), slot)
.await
.map_err(|_| GestureError::DeviceUnreachable(slot))?;
let mut armed = ArmedControls::default();
if let Err(error) = arm_controls_into(&device, chan, slot, spec, &mut armed).await {
armed.disarm().await;
return Err(error);
}
if armed.gesture_cids.is_empty()
&& armed.dpi_cids.is_empty()
&& armed.button_cids.is_empty()
&& armed.thumb.is_none()
{
debug!(slot, "no capturable controls — idle session");
}
Ok(armed)
}
async fn arm_controls_into(
device: &Device,
chan: &Arc<HidppChannel>,
slot: u8,
spec: &CaptureSpec,
armed: &mut ArmedControls,
) -> Result<(), GestureError> {
if let Some(info) = device
.root()
.get_feature(reprog_controls::FEATURE_ID)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
{
let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index);
let controls = enumerate_controls(&rc).await?;
armed.reprog = Some((rc.clone(), info.index));
for &cid in &spec.divert_gesture_sources {
if controls.iter().any(|c| c.cid == cid && c.supports_raw_xy()) {
let reporting = arm_reprog_control(&rc, cid, true).await?;
armed.reporting.push(reporting);
armed.gesture_cids.push(cid);
}
}
for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
let reporting = arm_reprog_control(&rc, cid, false).await?;
armed.reporting.push(reporting);
armed.dpi_cids.push(cid);
}
}
for &(cid, button) in &spec.divert_buttons {
if armed.gesture_cids.contains(&cid) {
continue;
}
if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
let reporting = arm_reprog_control(&rc, cid, false).await?;
armed.reporting.push(reporting);
armed.button_cids.push((cid, button));
}
}
}
if spec.capture_thumbwheel
&& let Some(info) = device
.root()
.get_feature(thumbwheel::FEATURE_ID)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
{
let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
let supports_single_tap = match tw.get_info().await {
Ok(twinfo) => twinfo.supports_single_tap,
Err(e) => {
warn!(error = ?e, "thumb wheel getInfo failed");
false
}
};
if !supports_single_tap {
debug!("thumb wheel reports no single tap — click not capturable");
}
if let Err(error) = tw.set_reporting(true, false).await {
let error = GestureError::Hidpp(format!("{error:?}"));
restore(
tw.set_reporting(false, false).await,
"failed thumb wheel diversion",
);
return Err(error);
}
armed.thumb = Some((tw, info.index));
}
Ok(())
}
async fn arm_reprog_control(
rc: &ReprogControlsV4,
cid: u16,
raw_xy: bool,
) -> Result<ArmedCid, GestureError> {
let original = rc
.get_cid_reporting(cid)
.await
.map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
if original.diverted {
debug!(cid, "control was already diverted before arming");
}
let mut change = reprog_controls::CidReportingChange::temporary_diversion(true, raw_xy);
change.remap = original.remap;
if let Err(error) = rc.set_cid_reporting_full(cid, change).await {
let error = GestureError::Hidpp(format!("{error:?}"));
restore_reporting(rc, ArmedCid { cid, original }, "failed diversion").await;
return Err(error);
}
Ok(ArmedCid { cid, original })
}
fn undivert_change(
reporting: reprog_controls::CidReporting,
) -> reprog_controls::CidReportingChange {
let mut change = reprog_controls::CidReportingChange::temporary_diversion(false, false);
change.remap = reporting.remap;
change
}
async fn restore_reporting(rc: &ReprogControlsV4, armed: ArmedCid, what: &str) {
let result = rc
.set_cid_reporting_full(armed.cid, undivert_change(armed.original))
.await
.map(|_| ());
restore(result, what);
}
fn gesture_source_button(cid: u16) -> Option<ButtonId> {
GESTURE_SOURCE_BUTTONS
.into_iter()
.find(|&(c, _)| c == cid)
.map(|(_, button)| button)
}
pub(crate) fn restore<E: std::fmt::Display>(result: Result<(), E>, what: &str) {
if let Err(e) = result {
warn!(error = %e, control = what, "failed to restore control mapping on shutdown");
}
}
pub(crate) async fn enumerate_controls(
rc: &ReprogControlsV4,
) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
let count = rc
.get_count()
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
let mut controls = Vec::with_capacity(usize::from(count));
for index in 0..count {
controls.push(
rc.get_ctrl_id_info(index)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?,
);
}
Ok(controls)
}
fn handle_reprog(
acc: &mut CaptureAccum,
event: RawControlEvent,
gesture_cids: &[u16],
dpi_cids: &[u16],
button_cids: &[(u16, ButtonId)],
sink: &mpsc::UnboundedSender<CapturedInput>,
) {
match event {
RawControlEvent::DivertedButtons(cids) => {
let held: Vec<(u16, ButtonId)> = gesture_cids
.iter()
.filter(|cid| cids.contains(cid))
.filter_map(|&cid| gesture_source_button(cid).map(|b| (cid, b)))
.collect();
match acc.gesture_source {
Some((cid, _)) if cids.contains(&cid) => {
acc.overlap = held.len() > 1;
}
previous => {
if let Some((_, button)) = previous {
acc.gesture_source = None;
acc.overlap = false;
if acc.swipe.end() {
debug!(%button, "gesture click");
let _ =
sink.send(CapturedInput::Gesture(button, GestureDirection::Click));
}
}
if let Some(&(cid, button)) = held.first() {
acc.gesture_source = Some((cid, button));
acc.swipe.begin();
acc.overlap = held.len() > 1;
acc.skip_first_raw_xy = cid == reprog_controls::HAPTIC_PANEL_CID
&& !acc.gestures_down.contains(&cid);
}
}
}
acc.gestures_down = held.into_iter().map(|(cid, _)| cid).collect();
let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
if dpi_down && !acc.dpi_down {
let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None));
}
acc.dpi_down = dpi_down;
for &(cid, button) in button_cids {
let down = cids.contains(&cid);
let was_down = acc.buttons_down.contains(&cid);
if down && !was_down {
let _ = sink.send(CapturedInput::ButtonPressed(button, None));
acc.buttons_down.push(cid);
} else if !down && was_down {
acc.buttons_down.retain(|&c| c != cid);
}
}
}
RawControlEvent::RawXy { dx, dy } => {
let Some((_, button)) = acc.gesture_source else {
return;
};
if acc.overlap {
return;
}
if acc.skip_first_raw_xy {
acc.skip_first_raw_xy = false;
return;
}
if let Some(direction) = acc.swipe.accumulate(i32::from(dx), i32::from(dy)) {
debug!(?direction, %button, "gesture committed");
let _ = sink.send(CapturedInput::Gesture(button, direction));
}
}
}
}
#[cfg(test)]
mod tests;