Skip to main content

hidpp/receiver/
unifying.rs

1//! Implements the Unifying Receiver.
2//!
3//! Unifying is a versatile receiver that can pair up to 6 devices using the
4//! 2.4 GHz eQuad radio protocol. It uses HID++ 1.0 registers for receiver
5//! control; paired devices speak HID++ 2.0 once addressed via their slot index.
6//!
7//! The register layout for device enumeration (`0xB5/0x5N`, `0xB5/0x6N`) is
8//! identical to Bolt's. The device-kind encoding differs from Bolt at values 5+
9//! (see [`DeviceKind`]).
10
11use std::sync::Arc;
12
13use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive};
14
15use crate::{
16    channel::{HidppChannel, MessageListenerGuard},
17    event::EventEmitter,
18    protocol::v10,
19    receiver::{RECEIVER_DEVICE_INDEX, ReceiverError},
20};
21
22/// All USB vendor & product ID pairs that are known to identify Unifying
23/// receivers.
24///
25/// `046d:c539` is the Lightspeed gaming receiver; `046d:c53f` is the Lightspeed
26/// nano receiver (bundled with G-series wireless mice such as the G305);
27/// `046d:c547` is the Lightspeed receiver bundled with newer G-series devices
28/// such as the G915 keyboard and the G502 X LIGHTSPEED. All answer the same
29/// HID++ 1.0 registers (pairing count, connection state, pairing information)
30/// as Unifying receivers. Callers that surface a user-facing receiver name
31/// label Lightspeed PIDs separately (see `openlogi-hid`).
32/// `0xc53f` was verified against a G305 (paired device wpid `0x4074`);
33/// `0xc547` against a G915 (paired device wpid `0x407c`).
34pub const VPID_PAIRS: &[(u16, u16)] = &[
35    (0x046d, 0xc52b),
36    (0x046d, 0xc532),
37    (0x046d, 0xc539),
38    (0x046d, 0xc53f),
39    (0x046d, 0xc547),
40];
41
42/// All known registers of the Unifying receiver.
43#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize))]
45#[non_exhaustive]
46#[repr(u8)]
47pub enum Register {
48    /// Controls which notifications the receiver emits. Wireless device-arrival
49    /// (`0x41`) events are only re-broadcast while wireless notifications are
50    /// enabled here; see [`Receiver::set_wireless_notifications`].
51    Notifications = 0x00,
52
53    /// Enables or disables wireless device-connection notifications; also used
54    /// to read the pairing count and to trigger device-arrival events.
55    Connections = 0x02,
56
57    /// Provides information about the receiver and paired devices. It uses
58    /// sub-registers, as defined in [`InfoSubRegister`], to differentiate
59    /// between different kinds of information.
60    ReceiverInfo = 0xb5,
61}
62
63/// Represents the known sub-registers of the [`Register::ReceiverInfo`]
64/// register.
65#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize))]
67#[non_exhaustive]
68#[repr(u8)]
69pub enum InfoSubRegister {
70    /// Provides general information about the receiver (serial number, pairing
71    /// slot count).
72    ReceiverInfo = 0x03,
73
74    /// Provides information about a specific paired device. The device index
75    /// (4 bits) must be added to this base address to form the actual
76    /// sub-register: `0x50 | (device_index & 0x0f)`.
77    DevicePairingInformation = 0x50,
78
79    /// Provides the codename of a specific paired device. The device index (4
80    /// bits) must be added: `0x60 | (device_index & 0x0f)`.
81    ///
82    /// NOTE: `0x60` is the *Bolt* base. Wire-verified Unifying receivers store
83    /// names at base `0x40 + (n-1)` instead, so name reads go directly through
84    /// `read_codename_unifying` in `inventory.rs` rather than this constant —
85    /// don't reuse `DeviceCodename` for Unifying name reads.
86    DeviceCodename = 0x60,
87}
88
89/// Implements the Unifying wireless receiver.
90#[derive(Clone)]
91pub struct Receiver {
92    chan: Arc<HidppChannel>,
93    emitter: Arc<EventEmitter<Event>>,
94    _listener: Arc<MessageListenerGuard>,
95}
96
97impl Receiver {
98    /// Tries to initialize a new [`Receiver`] from a raw HID++ channel.
99    ///
100    /// Returns [`ReceiverError::UnknownReceiver`] when the channel's VID/PID
101    /// doesn't match any known Unifying receiver.
102    pub fn new(chan: Arc<HidppChannel>) -> Result<Self, ReceiverError> {
103        if !VPID_PAIRS.contains(&(chan.vendor_id, chan.product_id)) {
104            return Err(ReceiverError::UnknownReceiver);
105        }
106
107        let emitter = Arc::new(EventEmitter::new());
108
109        let listener = chan.add_msg_listener_guarded({
110            let emitter = Arc::clone(&emitter);
111            move |raw, matched| {
112                // A report already matched to an outgoing request is a
113                // response, not a notification.
114                if matched {
115                    return;
116                }
117
118                if let Some(event) = decode_notification(&v10::Message::from(raw)) {
119                    emitter.emit(event);
120                }
121            }
122        });
123
124        Ok(Receiver {
125            _listener: Arc::new(listener),
126            chan,
127            emitter,
128        })
129    }
130
131    /// Creates a new listener for receiving receiver events.
132    #[must_use]
133    pub fn listen(&self) -> async_channel::Receiver<Event> {
134        self.emitter.create_receiver()
135    }
136
137    /// Counts the number of devices currently paired to this receiver.
138    /// Offline (sleeping) devices are included since pairings are persistent.
139    pub async fn count_pairings(&self) -> Result<u8, ReceiverError> {
140        let response = self
141            .chan
142            .read_register(
143                RECEIVER_DEVICE_INDEX,
144                Register::Connections.into(),
145                [0u8; 3],
146            )
147            .await?;
148
149        Ok(response[1])
150    }
151
152    /// Enables or disables wireless device-connection notifications.
153    ///
154    /// The receiver only re-broadcasts `0x41` device-arrival events (the source
155    /// for [`Self::trigger_device_arrival`]) while this is on. With it off the
156    /// trigger write is ACK'd but emits nothing — which is why a paired, online
157    /// device can fail to enumerate. Solaar enables this before listing.
158    ///
159    /// Read-modify-write of just the `WIRELESS` bit so it can't clobber other
160    /// flags already set on register `0x00` — notably `SOFTWARE_PRESENT` (0x08),
161    /// which the pairing flow enables (`pairing.rs` writes `[0x00, 0x09, 0x00]`)
162    /// and a concurrent inventory poll would otherwise drop.
163    pub async fn set_wireless_notifications(&self, enabled: bool) -> Result<(), ReceiverError> {
164        // Notification flags are a 3-byte big-endian word; the receiver-reporting
165        // bits live in byte 1 (WIRELESS = 0x000100, SOFTWARE_PRESENT = 0x000800).
166        const WIRELESS: u8 = 0x01;
167        let mut flags = self
168            .chan
169            .read_register(
170                RECEIVER_DEVICE_INDEX,
171                Register::Notifications.into(),
172                [0; 3],
173            )
174            .await?;
175        if enabled {
176            flags[1] |= WIRELESS;
177        } else {
178            flags[1] &= !WIRELESS;
179        }
180        self.chan
181            .write_register(RECEIVER_DEVICE_INDEX, Register::Notifications.into(), flags)
182            .await?;
183
184        Ok(())
185    }
186
187    /// Triggers device-arrival notifications for all currently connected
188    /// devices. Used to enumerate online devices at startup.
189    pub async fn trigger_device_arrival(&self) -> Result<(), ReceiverError> {
190        self.chan
191            .write_register(
192                RECEIVER_DEVICE_INDEX,
193                Register::Connections.into(),
194                [0x02, 0x00, 0x00],
195            )
196            .await?;
197
198        Ok(())
199    }
200
201    /// Provides general information about the receiver (serial number and
202    /// pairing slot count).
203    pub async fn get_receiver_info(&self) -> Result<ReceiverInfo, ReceiverError> {
204        let response = self
205            .chan
206            .read_long_register(
207                RECEIVER_DEVICE_INDEX,
208                Register::ReceiverInfo.into(),
209                [InfoSubRegister::ReceiverInfo.into(), 0, 0],
210            )
211            .await?;
212
213        Ok(ReceiverInfo {
214            serial_number: hex::encode_upper(&response[1..=4]),
215            pairing_slots: response[6],
216        })
217    }
218
219    /// Retrieves the pairing information for the device at `device_index`
220    /// (1-based slot number).
221    pub async fn get_device_pairing_information(
222        &self,
223        device_index: u8,
224    ) -> Result<DevicePairingInformation, ReceiverError> {
225        let response = self
226            .chan
227            .read_long_register(
228                RECEIVER_DEVICE_INDEX,
229                Register::ReceiverInfo.into(),
230                [
231                    u8::from(InfoSubRegister::DevicePairingInformation) | (device_index & 0x0f),
232                    0x00,
233                    0x00,
234                ],
235            )
236            .await?;
237
238        Ok(DevicePairingInformation {
239            wpid: u16::from_le_bytes([response[2], response[3]]),
240            // Kind is identity-only: an unrecognised nibble folds to
241            // `Unknown` instead of failing the whole pairing-info read.
242            kind: DeviceKind::from(response[1] & 0x0f),
243            encrypted: response[1] & (1 << 4) != 0,
244            online: response[1] & (1 << 6) == 0,
245            unit_id: [response[4], response[5], response[6], response[7]],
246        })
247    }
248
249    /// Provides the unique ID of the receiver (serial number).
250    pub async fn get_unique_id(&self) -> Result<String, ReceiverError> {
251        self.get_receiver_info().await.map(|i| i.serial_number)
252    }
253}
254
255/// The sub-id of the only notification this receiver emits: a paired device
256/// came online.
257const DEVICE_CONNECTION_SUB_ID: u8 = 0x41;
258
259/// Decodes an unsolicited receiver message into the event it carries, or
260/// `None` for a report this crate does not model.
261///
262/// Kept separate from the message listener in [`Receiver::new`] so the wire
263/// layout is reachable from tests without a HID channel behind it.
264fn decode_notification(msg: &v10::Message) -> Option<Event> {
265    let header = msg.header();
266    if header.sub_id != DEVICE_CONNECTION_SUB_ID {
267        return None;
268    }
269    let payload = msg.extend_payload();
270
271    // A connection notification is addressed to the device's own slot, which
272    // is the only place that index is reported.
273    Some(Event::DeviceConnection(DeviceConnection {
274        index: header.device_index,
275        // Kind is identity-only; an unrecognised nibble folds to `Unknown` —
276        // dropping the event would hide the device entirely, since arrival
277        // notifications are the only device source on this path.
278        kind: DeviceKind::from(payload[1] & 0x0f),
279        encrypted: payload[1] & (1 << 4) != 0,
280        online: payload[1] & (1 << 6) == 0,
281        wpid: u16::from_le_bytes([payload[2], payload[3]]),
282    }))
283}
284
285/// Represents some general information about a Unifying receiver.
286#[derive(Clone, PartialEq, Eq, Hash, Debug)]
287#[cfg_attr(feature = "serde", derive(serde::Serialize))]
288#[non_exhaustive]
289pub struct ReceiverInfo {
290    /// Receiver serial number.
291    pub serial_number: String,
292    /// Number of available pairing slots.
293    pub pairing_slots: u8,
294}
295
296/// Represents information about a paired device as read from the pairing
297/// register.
298#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
299#[cfg_attr(feature = "serde", derive(serde::Serialize))]
300#[non_exhaustive]
301pub struct DevicePairingInformation {
302    /// Wireless product ID of the paired device.
303    pub wpid: u16,
304    /// Device kind reported by the receiver.
305    pub kind: DeviceKind,
306    /// Whether the link is encrypted.
307    pub encrypted: bool,
308    /// Whether the device is currently online.
309    pub online: bool,
310    /// Device unit ID.
311    pub unit_id: [u8; 4],
312}
313
314/// Represents the kind of a device paired to a Unifying receiver.
315///
316/// The encoding matches Bolt for values 1–4; from 5 onwards Unifying uses a
317/// shifted table (Remote=5, Trackball=6, Touchpad=7) while Bolt reserves those
318/// values and places them at 7–9.
319#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)]
320#[cfg_attr(feature = "serde", derive(serde::Serialize))]
321#[non_exhaustive]
322#[repr(u8)]
323pub enum DeviceKind {
324    /// Unknown device kind — also the fold target for values this crate
325    /// does not model (kind is identity-only and must never drop an event).
326    #[num_enum(default)]
327    Unknown = 0x00,
328    /// Keyboard device.
329    Keyboard = 0x01,
330    /// Mouse device.
331    Mouse = 0x02,
332    /// Numeric keypad device.
333    Numpad = 0x03,
334    /// Presenter device.
335    Presenter = 0x04,
336    /// Remote-control device.
337    Remote = 0x05,
338    /// Trackball device.
339    Trackball = 0x06,
340    /// Touchpad device.
341    Touchpad = 0x07,
342}
343
344/// Represents a device-connection event fired by the receiver when a paired
345/// device comes online (or in response to [`Receiver::trigger_device_arrival`]).
346#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
347#[cfg_attr(feature = "serde", derive(serde::Serialize))]
348#[non_exhaustive]
349pub struct DeviceConnection {
350    /// Slot index (1-based) of the device.
351    pub index: u8,
352    /// Device kind reported by the receiver.
353    pub kind: DeviceKind,
354    /// Whether the link is encrypted.
355    pub encrypted: bool,
356    /// Whether the device is currently online.
357    pub online: bool,
358    /// Wireless product ID of the device.
359    pub wpid: u16,
360}
361
362/// Represents an event emitted by the Unifying receiver.
363#[derive(Clone, PartialEq, Eq, Hash, Debug)]
364#[cfg_attr(feature = "serde", derive(serde::Serialize))]
365#[non_exhaustive]
366pub enum Event {
367    /// Fired whenever a paired device connects or reconnects, and for all
368    /// online devices in response to [`Receiver::trigger_device_arrival`].
369    DeviceConnection(DeviceConnection),
370}
371
372#[cfg(test)]
373#[allow(
374    clippy::unwrap_used,
375    clippy::expect_used,
376    reason = "expect/unwrap are idiomatic in tests"
377)]
378mod tests {
379    use super::{DeviceConnection, DeviceKind, Event, decode_notification};
380    use crate::protocol::v10::{Message, MessageHeader};
381
382    /// Builds the long notification the receiver broadcasts, with `payload`
383    /// laid out exactly as the 17 bytes following the header.
384    fn notification(device_index: u8, sub_id: u8, payload: [u8; 17]) -> Message {
385        Message::Long(
386            MessageHeader {
387                device_index,
388                sub_id,
389            },
390            payload,
391        )
392    }
393
394    #[test]
395    fn device_connection_reads_the_slot_from_the_header() {
396        // The header byte is the only place the slot is reported.
397        let mut payload = [0u8; 17];
398        payload[1] = 0x02; // mouse, not encrypted, online
399        payload[2] = 0x74;
400        payload[3] = 0x40;
401
402        assert_eq!(
403            decode_notification(&notification(5, 0x41, payload)).unwrap(),
404            Event::DeviceConnection(DeviceConnection {
405                index: 5,
406                kind: DeviceKind::Mouse,
407                encrypted: false,
408                online: true,
409                wpid: 0x4074,
410            })
411        );
412    }
413
414    #[test]
415    fn encryption_sits_on_bit_4_unlike_bolt() {
416        // Unifying reports link encryption on bit 4; Bolt uses bit 5. Reading
417        // Bolt's bit here would report every encrypted link as plaintext.
418        let connection = |status: u8| {
419            let mut payload = [0u8; 17];
420            payload[1] = status;
421            match decode_notification(&notification(1, 0x41, payload)) {
422                Some(Event::DeviceConnection(connection)) => connection,
423                other => panic!("expected a device connection, got {other:?}"),
424            }
425        };
426
427        assert!(connection(1 << 4).encrypted);
428        assert!(!connection(1 << 5).encrypted);
429    }
430
431    #[test]
432    fn bit_6_is_set_when_the_device_is_offline() {
433        let mut payload = [0u8; 17];
434        payload[1] = 1 << 6;
435
436        let Some(Event::DeviceConnection(connection)) =
437            decode_notification(&notification(1, 0x41, payload))
438        else {
439            panic!("expected a device connection");
440        };
441        assert!(!connection.online);
442    }
443
444    #[test]
445    fn device_kind_uses_the_unifying_table_not_bolts() {
446        // Unifying and Bolt agree up to 4 and diverge from 5 on: `5` is a
447        // remote here but reserved on Bolt, which places its remote at 7.
448        let kind = |nibble: u8| {
449            let mut payload = [0u8; 17];
450            payload[1] = nibble;
451            match decode_notification(&notification(1, 0x41, payload)) {
452                Some(Event::DeviceConnection(connection)) => connection.kind,
453                other => panic!("expected a device connection, got {other:?}"),
454            }
455        };
456
457        assert_eq!(kind(0x05), DeviceKind::Remote);
458        assert_eq!(kind(0x06), DeviceKind::Trackball);
459        assert_eq!(kind(0x07), DeviceKind::Touchpad);
460    }
461
462    #[test]
463    fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
464        // Losing the event would hide the device from enumeration entirely,
465        // and arrival notifications are the only device source on this path.
466        let mut payload = [0u8; 17];
467        payload[1] = 0x0d;
468
469        let Some(Event::DeviceConnection(connection)) =
470            decode_notification(&notification(1, 0x41, payload))
471        else {
472            panic!("an unknown kind must still produce an event");
473        };
474        assert_eq!(connection.kind, DeviceKind::Unknown);
475    }
476
477    #[test]
478    fn other_sub_ids_are_dropped() {
479        assert_eq!(decode_notification(&notification(1, 0x40, [0u8; 17])), None);
480        assert_eq!(decode_notification(&notification(1, 0x4f, [0u8; 17])), None);
481    }
482
483    #[test]
484    fn short_notifications_decode_from_the_zero_padded_payload() {
485        let short = Message::Short(
486            MessageHeader {
487                device_index: 2,
488                sub_id: 0x41,
489            },
490            [0x00, 0x01, 0x74, 0x40],
491        );
492
493        assert_eq!(
494            decode_notification(&short).unwrap(),
495            Event::DeviceConnection(DeviceConnection {
496                index: 2,
497                kind: DeviceKind::Keyboard,
498                encrypted: false,
499                online: true,
500                wpid: 0x4074,
501            })
502        );
503    }
504}