Skip to main content

hidpp/
channel.rs

1//! Implements basic messaging across HID and HID++ channels.
2//!
3//! This includes mapping incoming messages to previously sent requests.
4
5use std::{
6    collections::{HashMap, VecDeque},
7    sync::{
8        Arc, Mutex, MutexGuard, Weak,
9        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
10    },
11    thread::{self, JoinHandle},
12    time::Duration,
13};
14
15use futures::{FutureExt, channel::oneshot, select};
16use rand::Rng;
17use tracing::trace;
18
19use crate::nibble::U4;
20
21mod error;
22mod message;
23mod raw;
24
25#[cfg(test)]
26#[allow(
27    clippy::unwrap_used,
28    clippy::expect_used,
29    reason = "expect/unwrap are idiomatic in tests"
30)]
31pub(crate) mod tests;
32
33pub use error::ChannelError;
34pub use message::{
35    HidppMessage, LONG_REPORT_ID, LONG_REPORT_LENGTH, SHORT_REPORT_ID, SHORT_REPORT_LENGTH,
36};
37pub use raw::RawHidChannel;
38
39use raw::supports_short_long_hidpp;
40
41/// This is the size of the buffer incoming reports are read into.
42/// As we only care about HID++ reports, this equals to [`LONG_REPORT_LENGTH`].
43const MAX_REPORT_LENGTH: usize = LONG_REPORT_LENGTH;
44
45/// Largest output report accepted by [`HidppChannel::write_raw_report`].
46/// Logitech's very-long HID++ lighting report (`0x12`) is 64 bytes.
47const MAX_RAW_REPORT_LENGTH: usize = 64;
48
49/// The default time budget for a [`HidppChannel::send`] request: the report
50/// write plus the wait for a matching response. Callers that need a different
51/// budget can use [`HidppChannel::send_with_timeout`].
52pub const SEND_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
53
54type MessageListener = Arc<dyn Fn(HidppMessage, bool) + Send + Sync + 'static>;
55
56/// Locks `mutex`, treating poisoning as unrecoverable: a panicking holder
57/// leaves the channel's shared queues in an inconsistent state, so
58/// continuing would operate on corrupt data.
59#[expect(
60    clippy::expect_used,
61    reason = "mutex poisoning is unrecoverable here — see doc comment"
62)]
63fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
64    mutex.lock().expect("mutex poisoned")
65}
66
67/// Removes a HID++ message listener when dropped.
68pub struct MessageListenerGuard {
69    message_listeners: Weak<Mutex<HashMap<u32, MessageListener>>>,
70    hdl: u32,
71}
72
73impl Drop for MessageListenerGuard {
74    fn drop(&mut self) {
75        if let Some(message_listeners) = self.message_listeners.upgrade() {
76            lock(&message_listeners).remove(&self.hdl);
77        }
78    }
79}
80
81/// Represents a HID communication channel supporting HID++.
82pub struct HidppChannel {
83    /// Whether the channel supports short (7 bytes) HID++ messages.
84    pub supports_short: bool,
85
86    /// Whether the channel supports long (20 bytes) HID++ messages.
87    pub supports_long: bool,
88
89    /// The vendor ID of the connected HID device.
90    pub vendor_id: u16,
91
92    /// The product ID of the connected HID device.
93    pub product_id: u16,
94
95    /// The underlying raw HID channel.
96    raw_channel: Arc<dyn RawHidChannel>,
97
98    /// Whether to rotate the [`Self::software_id`].
99    rotate_software_id: AtomicBool,
100
101    /// The software ID to provide at the next call to [`Self::get_sw_id`].
102    software_id: AtomicU8,
103
104    /// All sent messages that are waiting for a response.
105    pending_messages: Arc<Mutex<VecDeque<PendingMessage>>>,
106
107    /// The request ID assigned to the next pending message.
108    pending_message_id: AtomicU64,
109
110    /// Registered listeners that will receive notifications about incoming
111    /// messages.
112    message_listeners: Arc<Mutex<HashMap<u32, MessageListener>>>,
113
114    /// The sender signaling the read thread to stop.
115    read_thread_close: Option<oneshot::Sender<()>>,
116
117    /// The handle to the read thread. Should be joined after signaling
118    /// [`Self::read_thread_close`].
119    read_thread_hdl: Option<JoinHandle<()>>,
120
121    /// Optional process-wide software-id lease: `(id, free)` run on drop.
122    ///
123    /// OpenLogi leases a unique HID++ software id per open so concurrent
124    /// channels on the same physical HID node never share a correlation id
125    /// (software id `0` is reserved for device notifications). Local addition.
126    sw_id_lease: Option<(u8, fn(u8))>,
127}
128
129impl Drop for HidppChannel {
130    fn drop(&mut self) {
131        if let Some((id, free)) = self.sw_id_lease.take() {
132            free(id);
133        }
134
135        if let Some(read_thread_close) = self.read_thread_close.take() {
136            // This only fails if the receiving end, which is owned by the read thread in
137            // this case, is dropped.
138            // This just means that the read thread is already stopped, so we can ignore the
139            // error here.
140            let _ = read_thread_close.send(());
141        }
142
143        if let Some(read_thread_hdl) = self.read_thread_hdl.take() {
144            // A panic here means the read thread itself panicked; propagate
145            // it rather than silently ignore a crashed background worker.
146            #[expect(
147                clippy::unwrap_used,
148                reason = "propagate a read-thread panic instead of ignoring a crashed background worker"
149            )]
150            read_thread_hdl.join().unwrap();
151        }
152    }
153}
154
155/// Represents a message that was sent and is waiting for a response.
156struct PendingMessage {
157    /// Unique ID used to remove this request if it times out.
158    id: u64,
159
160    /// The predicate that has to match for an incoming message to be classified
161    /// as the response.
162    response_predicate: Box<dyn Fn(&HidppMessage) -> bool + Send>,
163
164    /// The oneshot sender used to provide the response message to the receiving
165    /// end.
166    sender: oneshot::Sender<HidppMessage>,
167}
168
169impl HidppChannel {
170    /// Tries to construct a HID++ channel from a raw HID channel.
171    ///
172    /// If the given HID channel does not support HID++,
173    /// [`ChannelError::HidppNotSupported`] will be returned.
174    pub async fn from_raw_channel(raw: impl RawHidChannel) -> Result<Self, ChannelError> {
175        let (supports_short, supports_long) = supports_short_long_hidpp(&raw).await?;
176
177        if !supports_short && !supports_long {
178            return Err(ChannelError::HidppNotSupported);
179        }
180
181        let raw_channel_rc = Arc::new(raw);
182        let pending_messages_rc = Arc::new(Mutex::new(VecDeque::<PendingMessage>::new()));
183        let message_listeners_rc = Arc::new(Mutex::new(HashMap::<u32, MessageListener>::new()));
184
185        let (close_sender, close_receiver) = oneshot::channel::<()>();
186
187        let read_thread_hdl = thread::spawn({
188            let raw_channel = Arc::clone(&raw_channel_rc);
189            let pending_messages = Arc::clone(&pending_messages_rc);
190            let message_listeners = Arc::clone(&message_listeners_rc);
191
192            move || {
193                futures::executor::block_on(read_loop(
194                    &*raw_channel,
195                    &pending_messages,
196                    &message_listeners,
197                    close_receiver,
198                ));
199            }
200        });
201
202        Ok(Self {
203            supports_short,
204            supports_long,
205            vendor_id: raw_channel_rc.vendor_id(),
206            product_id: raw_channel_rc.product_id(),
207            raw_channel: raw_channel_rc,
208            rotate_software_id: AtomicBool::new(false),
209            software_id: AtomicU8::new(0x01),
210            pending_messages: pending_messages_rc,
211            pending_message_id: AtomicU64::new(1),
212            message_listeners: message_listeners_rc,
213            read_thread_close: Some(close_sender),
214            read_thread_hdl: Some(read_thread_hdl),
215            sw_id_lease: None,
216        })
217    }
218
219    /// Whether the underlying HID transport still reports a live connection.
220    pub fn is_connected(&self) -> bool {
221        self.raw_channel.is_connected()
222    }
223
224    /// Sets the software ID that should be returned by the next call to
225    /// [`Self::get_sw_id`].
226    ///
227    /// Using software ID `0` is highly discouraged as it is used for device
228    /// notifications.
229    pub fn set_sw_id(&self, sw_id: U4) {
230        self.software_id.store(sw_id.to_lo(), Ordering::SeqCst);
231    }
232
233    /// Sets whether the software ID returned by a call to [`Self::get_sw_id`]
234    /// should increment (and potentially wrap around) after each call.
235    ///
236    /// This comes in handy when trying to map responses to requests
237    /// consistently.
238    ///
239    /// Software ID `0` will be skipped in the rotation process as it is
240    /// reserved for device notifications.
241    pub fn set_rotating_sw_id(&self, enable: bool) {
242        self.rotate_software_id.store(enable, Ordering::SeqCst);
243    }
244
245    /// Lease software id `id` until this channel is dropped, then call `free(id)`.
246    ///
247    /// Replaces any previous lease. Used by OpenLogi so concurrent opens of the
248    /// same HID node hold distinct correlation ids for their full lifetime.
249    ///
250    /// OpenLogi local addition.
251    pub fn set_sw_id_lease(&mut self, id: u8, free: fn(u8)) {
252        self.sw_id_lease = Some((id, free));
253    }
254
255    /// Provides a software ID that can be used to send a HID++ message across
256    /// the channel.
257    ///
258    /// This method should be called separately for every message to send as it
259    /// may rotate (as indicated by [`Self::set_rotating_sw_id`]).
260    pub fn get_sw_id(&self) -> U4 {
261        if self.rotate_software_id.load(Ordering::SeqCst) {
262            // The closure always returns `Some`, so `fetch_update` never
263            // reports `Err`; both arms carry the same pre-update value.
264            let previous =
265                match self
266                    .software_id
267                    .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |old| {
268                        Some(if old & 0x0f == 0x0f {
269                            0x01
270                        } else {
271                            old.wrapping_add(1)
272                        })
273                    }) {
274                    Ok(previous) | Err(previous) => previous,
275                };
276            U4::from_lo(previous)
277        } else {
278            U4::from_lo(self.software_id.load(Ordering::SeqCst))
279        }
280    }
281
282    /// Checks whether the channel supports the given HID++ message.
283    pub fn supports_msg(&self, msg: &HidppMessage) -> bool {
284        match msg {
285            HidppMessage::Short(_) => self.supports_short,
286            HidppMessage::Long(_) => self.supports_long,
287        }
288    }
289
290    /// Re-frames a short message as long on a long-only channel — a device that
291    /// exposes only the long HID++ report (e.g. a Bluetooth-LE-direct mouse on
292    /// macOS, where `IOHIDDeviceSetReport` rejects the short report). The HID++
293    /// header bytes sit at the same offsets in both widths, so the only change
294    /// is the report id plus zero-padding the extra payload; the device answers
295    /// with a long report, which still matches the request by header. A no-op on
296    /// channels that advertise short support.
297    ///
298    /// (OpenLogi local addition — candidate for upstreaming.)
299    fn normalize_outgoing(&self, msg: HidppMessage) -> HidppMessage {
300        match msg {
301            HidppMessage::Short(_) if !self.supports_short && self.supports_long => msg.widened(),
302            other => other,
303        }
304    }
305
306    /// Sends a HID++ message across the channel and waits for a response.
307    ///
308    /// If no response is expected/required, use [`Self::send_and_forget`].
309    ///
310    /// The whole request — the report write plus the wait for a matching
311    /// response — is bounded by [`SEND_RESPONSE_TIMEOUT`]; the future resolves
312    /// to [`ChannelError::Timeout`] on elapse. Use [`Self::send_with_timeout`]
313    /// to choose a different budget.
314    pub async fn send(
315        &self,
316        msg: HidppMessage,
317        response_predicate: impl Fn(&HidppMessage) -> bool + Send + 'static,
318    ) -> Result<HidppMessage, ChannelError> {
319        self.send_with_timeout(msg, response_predicate, SEND_RESPONSE_TIMEOUT)
320            .await
321    }
322
323    /// Sends a HID++ message across the channel and waits for a response,
324    /// bounding the whole request — the report write plus the wait for a
325    /// matching response — by `timeout`.
326    ///
327    /// On elapse the request's pending entry is removed (concurrent in-flight
328    /// requests are unaffected) and [`ChannelError::Timeout`] is returned; a
329    /// response that still arrives later reaches message listeners as an
330    /// unmatched message.
331    ///
332    /// [`Self::send`] uses this with [`SEND_RESPONSE_TIMEOUT`], which suits
333    /// requests to a device that may be asleep. Requests that should fail
334    /// faster — e.g. probing a receiver that answers immediately or not at
335    /// all — can pass a tighter budget.
336    pub async fn send_with_timeout(
337        &self,
338        msg: HidppMessage,
339        response_predicate: impl Fn(&HidppMessage) -> bool + Send + 'static,
340        timeout: Duration,
341    ) -> Result<HidppMessage, ChannelError> {
342        let msg = self.normalize_outgoing(msg);
343        if !self.supports_msg(&msg) {
344            return Err(ChannelError::MessageTypeNotSupported);
345        }
346
347        // Wire trace (off by default; `OPENLOGI_LOG=hidpp=trace`). Capture the
348        // header before `msg` is moved into the send future so the outcome line
349        // below can name the same request.
350        let (dev, feat, func) = msg.header();
351        trace!(dev, feat, func, "hidpp request");
352
353        let (sender, receiver) = oneshot::channel::<HidppMessage>();
354        let pending_id = self.pending_message_id.fetch_add(1, Ordering::SeqCst);
355
356        {
357            let mut pending = lock(&self.pending_messages);
358            // Drop abandoned requests before queuing this one. Timeouts and
359            // write failures remove their entry eagerly below, but a caller
360            // cancelled mid-flight (an outer `timeout(..)` dropping the whole
361            // future) still leaves its `PendingMessage` behind. On a channel
362            // reused across inventory ticks those would accumulate unboundedly
363            // — and a late response could be mis-delivered to a recycled
364            // software id. `is_canceled()` is true once the receiver is gone,
365            // so this prunes exactly the give-ups.
366            pending.retain(|m| !m.sender.is_canceled());
367            pending.push_back(PendingMessage {
368                id: pending_id,
369                response_predicate: Box::new(response_predicate),
370                sender,
371            });
372        }
373
374        // The deadline covers the write as well: `write_report` has no
375        // bounded-time contract of its own, so a wedged device could otherwise
376        // park `send` forever before the response wait even starts.
377        let mut request = std::pin::pin!(
378            async {
379                self.send_and_forget(msg).await?;
380                receiver.await.map_err(|_| ChannelError::NoResponse)
381            }
382            .fuse()
383        );
384
385        let result = select! {
386            result = request => result,
387            () = futures_timer::Delay::new(timeout).fuse() => Err(ChannelError::Timeout),
388        };
389
390        match &result {
391            Ok(_) => trace!(dev, feat, "hidpp response"),
392            Err(e) => trace!(dev, feat, error = ?e, "hidpp no response"),
393        }
394
395        if result.is_err() {
396            // A timeout or write failure leaves the entry queued — remove it
397            // eagerly. After a matched response the read thread has already
398            // taken it, so this is a no-op then.
399            self.remove_pending_message(pending_id);
400        }
401
402        result
403    }
404
405    fn remove_pending_message(&self, id: u64) {
406        let mut pending = lock(&self.pending_messages);
407        if let Some(pos) = pending.iter().position(|msg| msg.id == id) {
408            pending.remove(pos);
409        }
410    }
411
412    /// Sends a HID++ message across the channel and does not wait for a
413    /// response.
414    ///
415    /// If a response is expected, use [`Self::send`],
416    pub async fn send_and_forget(&self, msg: HidppMessage) -> Result<(), ChannelError> {
417        let msg = self.normalize_outgoing(msg);
418        if !self.supports_msg(&msg) {
419            return Err(ChannelError::MessageTypeNotSupported);
420        }
421
422        let mut buf = [0u8; LONG_REPORT_LENGTH];
423        let len = msg.write_raw(&mut buf);
424        self.raw_channel
425            .write_report(&buf[..len])
426            .await
427            .map(|_| ())
428            .map_err(ChannelError::Implementation)
429    }
430
431    /// Write one raw HID report through this channel's already-owned transport.
432    ///
433    /// Reports must contain `1..=64` bytes, including their report ID. The
434    /// operation is bounded by [`SEND_RESPONSE_TIMEOUT`] and returns the exact
435    /// byte count reported by the transport. This is intended for HID++ report
436    /// widths such as the 64-byte `0x12` lighting frame that [`HidppMessage`]
437    /// cannot represent.
438    pub async fn write_raw_report(&self, report: &[u8]) -> Result<usize, ChannelError> {
439        self.write_raw_report_with_timeout(report, SEND_RESPONSE_TIMEOUT)
440            .await
441    }
442
443    async fn write_raw_report_with_timeout(
444        &self,
445        report: &[u8],
446        timeout: Duration,
447    ) -> Result<usize, ChannelError> {
448        if !(1..=MAX_RAW_REPORT_LENGTH).contains(&report.len()) {
449            return Err(ChannelError::InvalidRawReportLength(report.len()));
450        }
451
452        let mut write = std::pin::pin!(self.raw_channel.write_report(report).fuse());
453        select! {
454            result = write => result.map_err(ChannelError::Implementation),
455            () = futures_timer::Delay::new(timeout).fuse() => Err(ChannelError::Timeout),
456        }
457    }
458
459    /// Registers a listener that will be called for every incoming message.
460    ///
461    /// Returns a handle that can be used to remove the listener using a call to
462    /// [`Self::remove_msg_listener`].
463    pub fn add_msg_listener(
464        &self,
465        listener: impl Fn(HidppMessage, bool) + Send + Sync + 'static,
466    ) -> u32 {
467        let mut listeners = lock(&self.message_listeners);
468
469        let mut rng = rand::rng();
470        let mut hdl = rng.random::<u32>();
471        while listeners.contains_key(&hdl) {
472            hdl = rng.random::<u32>();
473        }
474
475        listeners.insert(hdl, Arc::new(listener));
476        hdl
477    }
478
479    /// Registers a listener that is automatically removed when the returned
480    /// guard is dropped.
481    pub fn add_msg_listener_guarded(
482        &self,
483        listener: impl Fn(HidppMessage, bool) + Send + Sync + 'static,
484    ) -> MessageListenerGuard {
485        let hdl = self.add_msg_listener(listener);
486        MessageListenerGuard {
487            message_listeners: Arc::downgrade(&self.message_listeners),
488            hdl,
489        }
490    }
491
492    /// Removes a previously registered message listener.
493    ///
494    /// Returns whether a listener was found using the given handle.
495    pub fn remove_msg_listener(&self, hdl: u32) -> bool {
496        lock(&self.message_listeners).remove(&hdl).is_some()
497    }
498}
499
500/// Reads reports from `raw_channel` until `close` fires, resolving each one
501/// against the pending requests and then handing it to every listener.
502///
503/// Runs on the channel's dedicated read thread. `read_report` is always raced
504/// against `close` so a transport that parks forever on a dead device still
505/// lets the channel shut down — see [`RawHidChannel::read_report`].
506async fn read_loop(
507    raw_channel: &dyn RawHidChannel,
508    pending_messages: &Mutex<VecDeque<PendingMessage>>,
509    message_listeners: &Mutex<HashMap<u32, MessageListener>>,
510    mut close: oneshot::Receiver<()>,
511) {
512    let mut buf = [0u8; MAX_REPORT_LENGTH];
513
514    loop {
515        let res = select! {
516            _ = close => break,
517            res = raw_channel.read_report(&mut buf).fuse() => res,
518        };
519
520        let len = match res {
521            Ok(len) => len,
522            Err(error) => {
523                // A silently erroring handle is indistinguishable from a deaf
524                // one without this line.
525                trace!(?error, "read_report error");
526                continue;
527            }
528        };
529
530        let Some(msg) = HidppMessage::read_raw(&buf[..len]) else {
531            trace!(len, "report not HID++ — dropped");
532            continue;
533        };
534
535        let mut matched = false;
536        let pending_count;
537        {
538            let mut msgs = lock(pending_messages);
539            pending_count = msgs.len();
540            if let Some(pos) = msgs.iter().position(|elem| (elem.response_predicate)(&msg))
541                && let Some(waiting) = msgs.remove(pos)
542            {
543                let _ = waiting.sender.send(msg);
544                matched = true;
545            }
546        }
547
548        trace!(
549            len,
550            matched,
551            pending_count,
552            payload = format!("{:02x?}", &buf[..len.min(16)]),
553            "raw report received"
554        );
555
556        // Collected before dispatch so a listener may add or remove listeners
557        // without deadlocking on the lock it is being called under.
558        let listeners: Vec<_> = lock(message_listeners).values().cloned().collect();
559        for listener in listeners {
560            listener(msg, matched);
561        }
562    }
563}