mod liveness;
use std::sync::{Arc, Mutex, PoisonError};
use hidpp::{
channel::HidppChannel,
device::Device,
feature::{
CreatableFeature, EmittingFeature,
root::RootFeature,
wireless_device_status::{WirelessDeviceStatusEvent, WirelessDeviceStatusFeature},
},
protocol::v20,
};
use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
use crate::backend::{BackendError, HidBackend};
use crate::channel::route::{DeviceRoute, open_route_channel};
use crate::{ChannelRegistry, DeviceIoGate, SharedChannel};
use liveness::{CaptureLiveness, ChannelActivity, LivenessDecision, PingOutcome};
#[cfg(test)]
use super::capture_restore::undivert_change;
use super::capture_restore::{
ArmedReporting, CaptureStop, ReprogRestore, divert_change, drop_listener_after,
restore_after_stop, rollback_capture_start, stop_for_current_publication,
wait_for_channel_change,
};
pub use super::capture_restore::{
CaptureChannel, CaptureSessionFailure, CaptureSessionOutcome, GestureError,
PendingCaptureRestore,
};
use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
use crate::thumbwheel::{self, Thumbwheel, ThumbwheelInfo, WheelDirection, WheelResolution};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapturedInput {
Gesture(ButtonId, GestureDirection),
ButtonDown(ButtonId),
Scroll {
increments: i16,
resolution: WheelResolution,
},
ThumbwheelDirection {
positive_is_forward: bool,
},
ButtonUp(ButtonId),
ButtonPulse(ButtonId),
}
#[derive(Default)]
enum HoldState {
#[default]
Idle,
Holding {
cid: u16,
button: ButtonId,
swipe: SwipeAccumulator,
overlap: bool,
skip_first_raw_xy: bool,
},
}
fn begin_hold(cid: u16, button: ButtonId, overlap: bool, skip_first_raw_xy: bool) -> HoldState {
let mut swipe = SwipeAccumulator::default();
swipe.begin();
HoldState::Holding {
cid,
button,
swipe,
overlap,
skip_first_raw_xy,
}
}
#[derive(Default)]
struct CaptureAccum {
hold: HoldState,
gestures_down: Vec<u16>,
dpi_down: bool,
buttons_down: Vec<u16>,
}
#[cfg(test)]
impl CaptureAccum {
fn backdate_hold_for_test(&mut self) {
if let HoldState::Holding { swipe, .. } = &mut self.hold {
swipe.backdate_hold_for_test();
}
}
}
pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 9] = [
(0x0052, ButtonId::MiddleClick),
(0x0053, ButtonId::Back),
(0x00BD, ButtonId::Back),
(0x00CE, ButtonId::Back),
(0x00DB, ButtonId::Back),
(0x0056, ButtonId::Forward),
(0x00CF, ButtonId::Forward),
(0x005b, ButtonId::WheelTiltLeft),
(0x005d, ButtonId::WheelTiltRight),
];
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_gesture_buttons: Vec<(u16, ButtonId)>,
pub divert_buttons: Vec<(u16, ButtonId)>,
}
pub async fn run_capture_session(
backend: &dyn HidBackend,
route: DeviceRoute,
spec: CaptureSpec,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<()>,
channel_slot: CaptureChannel,
device_io: DeviceIoGate,
) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
if !device_io.allows_io() {
return Err(GestureError::Hid(BackendError::Backend(
"host device I/O is suspended".into(),
))
.into());
}
let chan = open_route_channel(backend, &route)
.await
.map_err(GestureError::from)?
.ok_or(GestureError::DeviceNotFound)?;
let shared = SharedChannel::new(chan, route.clone());
run_capture_session_on(shared, spec, sink, shutdown, channel_slot, None, device_io).await
}
pub async fn run_capture_session_with_registry_spec(
route: DeviceRoute,
spec: CaptureSpec,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<()>,
channel_slot: CaptureChannel,
registry: &ChannelRegistry,
device_io: DeviceIoGate,
) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
let shared = registry
.lookup(&route)
.ok_or(GestureError::DeviceNotFound)?;
run_capture_session_on(
shared,
spec,
sink,
shutdown,
channel_slot,
Some(registry),
device_io,
)
.await
}
async fn run_capture_session_on(
shared: SharedChannel,
spec: CaptureSpec,
sink: mpsc::UnboundedSender<CapturedInput>,
shutdown: oneshot::Receiver<()>,
channel_slot: CaptureChannel,
registry: Option<&ChannelRegistry>,
device_io: DeviceIoGate,
) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
if !device_io.allows_io() {
return Err(GestureError::Hid(BackendError::Backend(
"host device I/O is suspended".into(),
))
.into());
}
let chan = Arc::clone(shared.channel());
let device_index = shared.device_index();
let armed = arm_controls(&chan, device_index, &spec, &shared, registry).await?;
if let Some(direction) = armed.thumbwheel_direction() {
let _ = sink.send(direction);
}
if let Ok(mut slot) = channel_slot.write() {
*slot = Some(shared.clone());
}
let accum = Arc::new(Mutex::new(CaptureAccum::default()));
let reprog_index = armed.reprog.as_ref().map(ReprogControlsV4::feature_index);
let gesture_cids = armed.gesture_cids.clone();
let gesture_button_set = armed.gesture_button_cids.clone();
let thumb_index = armed
.thumb
.as_ref()
.map(|thumb| thumb.wheel.feature_index());
let thumb_resolution = armed
.thumb
.as_ref()
.map_or(WheelResolution::UNKNOWN, ArmedThumbwheel::resolution);
let dpi_set = armed.dpi_cids.clone();
let button_set = armed.button_cids.clone();
let activity = Arc::new(ChannelActivity::default());
let listener = chan.add_msg_listener_guarded({
let accum = Arc::clone(&accum);
let activity = Arc::clone(&activity);
let sink = sink.clone();
move |raw, matched| {
activity.record();
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_with_gesture_buttons(
&mut acc,
event,
&gesture_cids,
&dpi_set,
&gesture_button_set,
&button_set,
&sink,
);
return;
}
if let Some(idx) = thumb_index
&& let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
&& let Some(input) = thumbwheel_input(event, thumb_resolution)
{
let _ = sink.send(input);
}
}
});
let root = RootFeature::new(Arc::clone(&chan), device_index, 0);
let wireless = root
.get_feature(WirelessDeviceStatusFeature::ID)
.await
.ok()
.flatten()
.map(|info| WirelessDeviceStatusFeature::new(Arc::clone(&chan), device_index, info.index));
log_capture_active(device_index, &armed, wireless.is_some());
let stop = monitor_capture(
CaptureMonitor {
root: &root,
armed: &armed,
accum: &accum,
device_index,
registry,
shared: &shared,
activity: &activity,
},
wireless,
shutdown,
device_io,
)
.await;
if let Ok(mut slot) = channel_slot.write()
&& slot
.as_ref()
.is_some_and(|shared| Arc::ptr_eq(shared.channel(), &chan))
{
*slot = None;
}
let outcome = finish_capture(listener, stop, armed, shared, registry).await;
debug!(index = device_index, "control capture stopped");
Ok(outcome)
}
async fn finish_capture<T>(
listener: T,
stop: CaptureStop,
armed: ArmedControls,
retired: SharedChannel,
registry: Option<&ChannelRegistry>,
) -> CaptureSessionOutcome {
let pending = armed.into_pending(&retired);
drop_listener_after(
listener,
restore_after_stop(stop, pending, &retired, registry),
)
.await
}
fn thumbwheel_input(
event: thumbwheel::ThumbwheelEvent,
resolution: WheelResolution,
) -> Option<CapturedInput> {
if event.rotation != 0 {
return Some(CapturedInput::Scroll {
increments: event.rotation,
resolution,
});
}
if event.rotation_status.is_rolling() {
return None;
}
event
.single_tap
.then_some(CapturedInput::ButtonPulse(ButtonId::Thumbwheel))
}
#[derive(Default)]
struct ArmedControls {
reprog: Option<ReprogControlsV4>,
gesture_cids: Vec<u16>,
gesture_button_cids: Vec<(u16, ButtonId)>,
dpi_cids: Vec<u16>,
button_cids: Vec<(u16, ButtonId)>,
reporting: Vec<ArmedReporting>,
thumb: Option<ArmedThumbwheel>,
}
struct ArmedThumbwheel {
wheel: Thumbwheel,
info: Option<ThumbwheelInfo>,
}
impl ArmedThumbwheel {
fn resolution(&self) -> WheelResolution {
self.info
.map_or(WheelResolution::UNKNOWN, |info| info.resolution)
}
fn direction(&self) -> WheelDirection {
if self.info.is_some_and(|info| !info.positive_is_forward()) {
WheelDirection::Inverted
} else {
WheelDirection::Default
}
}
}
impl ArmedControls {
fn thumbwheel_direction(&self) -> Option<CapturedInput> {
let positive_is_forward = self
.thumb
.as_ref()?
.info
.map(ThumbwheelInfo::positive_is_forward)?;
Some(CapturedInput::ThumbwheelDirection {
positive_is_forward,
})
}
fn into_pending(self, retired: &SharedChannel) -> Option<PendingCaptureRestore> {
let Self {
reprog,
reporting,
thumb,
..
} = self;
let reprog =
reprog.and_then(|controls| ReprogRestore::new(controls.feature_index(), reporting));
PendingCaptureRestore::new(
retired,
reprog,
thumb.as_ref().map(|thumb| thumb.wheel.feature_index()),
)
}
async fn rearm(&self, device_io: &DeviceIoGate) {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if !device_io.allows_io() {
return;
}
if let Some(rc) = self.reprog.as_ref() {
for &reporting in &self.reporting {
let raw_xy = self.gesture_cids.contains(&reporting.cid)
|| self
.gesture_button_cids
.iter()
.any(|&(cid, _)| cid == reporting.cid);
let change = divert_change(reporting.original, raw_xy);
if let Err(error) = rc.set_cid_reporting_full(reporting.cid, change).await {
warn!(
cid = format_args!("{:#06x}", reporting.cid),
?error,
"re-divert after wake failed"
);
}
}
}
if let Some(thumb) = self.thumb.as_ref()
&& let Err(error) = thumb.wheel.divert(thumb.direction()).await
{
warn!(?error, "thumb-wheel re-divert after wake failed");
}
}
}
fn log_capture_active(device_index: u8, armed: &ArmedControls, wake_rearm: bool) {
info!(
index = device_index,
gesture_sources = armed.gesture_cids.len(),
gesture_buttons = armed.gesture_button_cids.len(),
dpi_buttons = armed.dpi_cids.len(),
buttons = armed.button_cids.len(),
thumbwheel = armed.thumb.is_some(),
wake_rearm,
"control capture active"
);
}
struct CaptureMonitor<'a> {
root: &'a RootFeature,
armed: &'a ArmedControls,
accum: &'a Arc<Mutex<CaptureAccum>>,
device_index: u8,
registry: Option<&'a ChannelRegistry>,
shared: &'a SharedChannel,
activity: &'a ChannelActivity,
}
async fn monitor_capture(
context: CaptureMonitor<'_>,
wireless: Option<WirelessDeviceStatusFeature>,
shutdown: oneshot::Receiver<()>,
mut device_io: DeviceIoGate,
) -> CaptureStop {
let mut wake_events = wireless.as_ref().map(EmittingFeature::listen);
let mut shutdown = std::pin::pin!(shutdown);
let mut liveness =
CaptureLiveness::new(tokio::time::Instant::now(), context.activity.generation());
loop {
if !device_io.allows_io() {
if !device_io.wait_until_allowed().await {
return stop_for_current_publication(context.registry, context.shared);
}
liveness.record_activity(tokio::time::Instant::now(), context.activity.generation());
}
let activity_generation = liveness.activity_generation();
let idle_deadline = liveness.idle_deadline();
tokio::select! {
biased;
allowed = device_io.changed() => {
match allowed {
Some(true) => liveness.record_activity(
tokio::time::Instant::now(),
context.activity.generation(),
),
Some(false) => {}
None => return stop_for_current_publication(context.registry, context.shared),
}
}
transition = wait_for_channel_change(
context.registry,
context.shared,
) => {
info!(index = context.device_index, "inventory replaced or removed capture channel — restarting session");
return transition;
}
_ = &mut shutdown => {
return stop_for_current_publication(context.registry, context.shared);
}
event = async {
match wake_events.as_ref() {
Some(events) => events.recv().await.ok(),
None => std::future::pending().await,
}
} => {
let Some(WirelessDeviceStatusEvent::StatusBroadcast(broadcast)) = event else {
wake_events = None;
continue;
};
info!(?broadcast, "device reconnected — re-arming control capture");
*context.accum.lock().unwrap_or_else(PoisonError::into_inner) =
CaptureAccum::default();
context.armed.rearm(&device_io).await;
}
generation = context.activity.changed_after(activity_generation) => {
liveness.record_activity(tokio::time::Instant::now(), generation);
}
() = tokio::time::sleep_until(idle_deadline) => {
if !liveness.ping_due(
tokio::time::Instant::now(),
context.activity.generation(),
) {
continue;
}
let outcome = match context.root.ping(0x5a).await {
Err(v20::Hidpp20Error::Channel(
hidpp::channel::ChannelError::Timeout
| hidpp::channel::ChannelError::NoResponse,
)) => PingOutcome::AllSilent,
Ok(_)
| Err(
v20::Hidpp20Error::Feature(_)
| v20::Hidpp20Error::UnsupportedResponse,
) => PingOutcome::Delivered,
Err(_) => PingOutcome::ChannelFailed,
};
if liveness.finish_ping(
tokio::time::Instant::now(),
context.activity.generation(),
outcome,
) == LivenessDecision::Restart {
warn!(index = context.device_index, "capture channel stopped delivering — restarting session on a fresh channel");
return stop_for_current_publication(context.registry, context.shared);
}
}
}
}
}
async fn arm_controls(
chan: &Arc<HidppChannel>,
slot: u8,
spec: &CaptureSpec,
shared: &SharedChannel,
registry: Option<&ChannelRegistry>,
) -> Result<ArmedControls, CaptureSessionFailure> {
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 {
let pending = armed.into_pending(shared);
return Err(rollback_capture_start(error, pending, shared, registry).await);
}
if armed.gesture_cids.is_empty()
&& armed.gesture_button_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());
for &cid in &spec.divert_gesture_sources {
if controls.iter().any(|c| c.cid == cid && c.supports_raw_xy()) {
arm_reprog_control(&rc, cid, true, &mut armed.reporting).await?;
armed.gesture_cids.push(cid);
}
}
for &(cid, button) in &spec.divert_gesture_buttons {
if let Some(control) = controls.iter().find(|c| c.cid == cid)
&& control.is_divertable()
&& control.supports_raw_xy()
{
arm_reprog_control(&rc, cid, true, &mut armed.reporting).await?;
armed.gesture_button_cids.push((cid, button));
}
}
for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?;
armed.dpi_cids.push(cid);
}
}
for &(cid, button) in &spec.divert_buttons {
if armed.gesture_cids.contains(&cid)
|| armed
.gesture_button_cids
.iter()
.any(|&(gesture_cid, _)| gesture_cid == cid)
{
continue;
}
if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
arm_reprog_control(&rc, cid, false, &mut armed.reporting).await?;
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 wheel_info = match tw.get_info().await {
Ok(twinfo) => Some(twinfo),
Err(e) => {
warn!(error = ?e, "thumb wheel getInfo failed");
None
}
};
if wheel_info.is_some_and(|info| !info.supports_single_tap) {
debug!("thumb wheel reports no single tap — click not capturable");
}
armed.thumb = Some(ArmedThumbwheel {
wheel: tw,
info: wheel_info,
});
if let Some(thumb) = armed.thumb.as_ref()
&& let Err(error) = thumb.wheel.divert(thumb.direction()).await
{
return Err(GestureError::Hidpp(format!("{error:?}")));
}
}
Ok(())
}
async fn arm_reprog_control(
rc: &ReprogControlsV4,
cid: u16,
raw_xy: bool,
reporting: &mut Vec<ArmedReporting>,
) -> Result<(), 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 change = divert_change(original, raw_xy);
reporting.push(ArmedReporting { cid, original });
rc.set_cid_reporting_full(cid, change)
.await
.map_err(|error| GestureError::Hidpp(format!("{error:?}")))?;
Ok(())
}
fn gesture_source_button(cid: u16) -> Option<ButtonId> {
GESTURE_SOURCE_BUTTONS
.into_iter()
.find(|&(c, _)| c == cid)
.map(|(_, button)| button)
}
fn captured_gesture_button(cid: u16, gesture_button_cids: &[(u16, ButtonId)]) -> Option<ButtonId> {
gesture_source_button(cid).or_else(|| {
gesture_button_cids
.iter()
.find(|&&(candidate, _)| candidate == cid)
.map(|&(_, button)| button)
})
}
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_with_gesture_buttons(
acc: &mut CaptureAccum,
event: RawControlEvent,
gesture_cids: &[u16],
dpi_cids: &[u16],
gesture_button_cids: &[(u16, ButtonId)],
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)))
.chain(
gesture_button_cids
.iter()
.copied()
.filter(|(cid, _)| cids.contains(cid)),
)
.collect();
acc.hold = match std::mem::take(&mut acc.hold) {
HoldState::Holding {
cid,
button,
swipe,
skip_first_raw_xy,
..
} if cids.contains(&cid) => HoldState::Holding {
cid,
button,
swipe,
overlap: held.len() > 1,
skip_first_raw_xy,
},
previous => {
if let HoldState::Holding {
button, mut swipe, ..
} = previous
&& swipe.end()
{
debug!(%button, "gesture click");
let _ = sink.send(CapturedInput::Gesture(button, GestureDirection::Click));
}
match held.first() {
Some(&(cid, button)) => begin_hold(
cid,
button,
held.len() > 1,
cid == reprog_controls::HAPTIC_PANEL_CID
&& !acc.gestures_down.contains(&cid),
),
None => HoldState::Idle,
}
}
};
for &cid in &acc.gestures_down {
if !held.iter().any(|(held_cid, _)| *held_cid == cid)
&& let Some(button) = captured_gesture_button(cid, gesture_button_cids)
{
let _ = sink.send(CapturedInput::ButtonUp(button));
}
}
for &(cid, button) in &held {
if !acc.gestures_down.contains(&cid) {
let _ = sink.send(CapturedInput::ButtonDown(button));
}
}
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::ButtonDown(ButtonId::DpiToggle));
} else if !dpi_down && acc.dpi_down {
let _ = sink.send(CapturedInput::ButtonUp(ButtonId::DpiToggle));
}
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::ButtonDown(button));
acc.buttons_down.push(cid);
} else if !down && was_down {
let _ = sink.send(CapturedInput::ButtonUp(button));
acc.buttons_down.retain(|&c| c != cid);
}
}
}
RawControlEvent::RawXy { dx, dy } => {
handle_raw_xy(acc, dx, dy, sink);
}
}
}
fn handle_raw_xy(
acc: &mut CaptureAccum,
dx: i16,
dy: i16,
sink: &mpsc::UnboundedSender<CapturedInput>,
) {
let HoldState::Holding {
button,
swipe,
overlap,
skip_first_raw_xy,
..
} = &mut acc.hold
else {
return;
};
if *overlap {
return;
}
if *skip_first_raw_xy {
*skip_first_raw_xy = false;
return;
}
if let Some(direction) = swipe.accumulate(i32::from(dx), i32::from(dy)) {
debug!(?direction, %button, "gesture committed");
let _ = sink.send(CapturedInput::Gesture(*button, direction));
}
}
#[cfg(test)]
fn handle_reprog(
acc: &mut CaptureAccum,
event: RawControlEvent,
gesture_cids: &[u16],
dpi_cids: &[u16],
button_cids: &[(u16, ButtonId)],
sink: &mpsc::UnboundedSender<CapturedInput>,
) {
handle_reprog_with_gesture_buttons(acc, event, gesture_cids, dpi_cids, &[], button_cids, sink);
}
#[cfg(test)]
mod tests;