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    emitter::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:c537` is the Nano receiver bundled with the G602;
26/// `046d:c539` is the Lightspeed gaming receiver; `046d:c53f` is the Lightspeed
27/// nano receiver (bundled with G-series wireless mice such as the G305);
28/// `046d:c547` is the Lightspeed receiver bundled with newer G-series devices
29/// such as the G915 keyboard and the G502 X LIGHTSPEED; `046d:c54d` ships with
30/// the PRO X SUPERLIGHT 2 DEX. All answer the same
31/// HID++ 1.0 registers (pairing count, connection state, pairing information)
32/// as Unifying receivers. Callers that surface a user-facing receiver name
33/// label Lightspeed PIDs separately (see `openlogi-hid`).
34/// `0xc53f` was verified against a G305 (paired device wpid `0x4074`);
35/// `0xc547` against a G915 (paired device wpid `0x407c`); `0xc54d` against a
36/// PRO X SUPERLIGHT 2 DEX.
37pub const VPID_PAIRS: &[(u16, u16)] = &[
38    (0x046d, 0xc52b),
39    (0x046d, 0xc532),
40    (0x046d, 0xc537),
41    (0x046d, 0xc539),
42    (0x046d, 0xc53f),
43    (0x046d, 0xc547),
44    (0x046d, 0xc54d),
45];
46
47/// All known registers of the Unifying receiver.
48#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
49#[cfg_attr(feature = "serde", derive(serde::Serialize))]
50#[non_exhaustive]
51#[repr(u8)]
52pub enum Register {
53    /// Controls which notifications the receiver emits. Wireless device-arrival
54    /// (`0x41`) events are only re-broadcast while wireless notifications are
55    /// enabled here; see [`Receiver::set_wireless_notifications`].
56    Notifications = 0x00,
57
58    /// Enables or disables wireless device-connection notifications; also used
59    /// to read the pairing count and to trigger device-arrival events.
60    Connections = 0x02,
61
62    /// Provides information about the receiver and paired devices. It uses
63    /// sub-registers, as defined in [`InfoSubRegister`], to differentiate
64    /// between different kinds of information.
65    ReceiverInfo = 0xb5,
66}
67
68/// Represents the known sub-registers of the [`Register::ReceiverInfo`]
69/// register.
70#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize))]
72#[non_exhaustive]
73#[repr(u8)]
74pub enum InfoSubRegister {
75    /// Provides general information about the receiver (serial number, pairing
76    /// slot count).
77    ReceiverInfo = 0x03,
78
79    /// Provides information about a specific paired device. The device index
80    /// (4 bits) must be added to this base address to form the actual
81    /// sub-register: `0x50 | (device_index & 0x0f)`.
82    DevicePairingInformation = 0x50,
83
84    /// Provides the codename of a specific paired device. The device index (4
85    /// bits) must be added: `0x60 | (device_index & 0x0f)`.
86    ///
87    /// NOTE: `0x60` is the *Bolt* base. Wire-verified Unifying receivers store
88    /// names at base `0x40 + (n-1)` instead, so name reads go directly through
89    /// `read_codename_unifying` in `inventory.rs` rather than this constant —
90    /// don't reuse `DeviceCodename` for Unifying name reads.
91    DeviceCodename = 0x60,
92}
93
94/// Implements the Unifying wireless receiver.
95#[derive(Clone)]
96pub struct Receiver {
97    chan: Arc<HidppChannel>,
98    emitter: Arc<EventEmitter<Event>>,
99    _listener: Arc<MessageListenerGuard>,
100}
101
102impl Receiver {
103    /// Tries to initialize a new [`Receiver`] from a raw HID++ channel.
104    ///
105    /// Returns [`ReceiverError::UnknownReceiver`] when the channel's VID/PID
106    /// doesn't match any known Unifying receiver.
107    pub fn new(chan: Arc<HidppChannel>) -> Result<Self, ReceiverError> {
108        if !VPID_PAIRS.contains(&(chan.vendor_id, chan.product_id)) {
109            return Err(ReceiverError::UnknownReceiver);
110        }
111
112        let emitter = Arc::new(EventEmitter::new());
113
114        let listener = chan.add_msg_listener_guarded({
115            let emitter = Arc::clone(&emitter);
116            move |raw, matched| {
117                // A report already matched to an outgoing request is a
118                // response, not a notification.
119                if matched {
120                    return;
121                }
122
123                if let Some(event) = decode_notification(&v10::Message::from(raw)) {
124                    emitter.emit(event);
125                }
126            }
127        });
128
129        Ok(Receiver {
130            _listener: Arc::new(listener),
131            chan,
132            emitter,
133        })
134    }
135
136    /// Creates a new listener for receiving receiver events.
137    #[must_use]
138    pub fn listen(&self) -> async_channel::Receiver<Event> {
139        self.emitter.create_receiver()
140    }
141
142    /// Counts the number of devices currently paired to this receiver.
143    /// Offline (sleeping) devices are included since pairings are persistent.
144    pub async fn count_pairings(&self) -> Result<u8, ReceiverError> {
145        let response = self
146            .chan
147            .read_register(
148                RECEIVER_DEVICE_INDEX,
149                Register::Connections.into(),
150                [0u8; 3],
151            )
152            .await?;
153
154        Ok(response[1])
155    }
156
157    /// Enables or disables wireless device-connection notifications.
158    ///
159    /// The receiver only re-broadcasts `0x41` device-arrival events (the source
160    /// for [`Self::trigger_device_arrival`]) while this is on. With it off the
161    /// trigger write is ACK'd but emits nothing — which is why a paired, online
162    /// device can fail to enumerate. Solaar enables this before listing.
163    ///
164    /// Read-modify-write of just the `WIRELESS` bit so it can't clobber other
165    /// flags already set on register `0x00` — notably `SOFTWARE_PRESENT` (0x08),
166    /// which the pairing flow enables (`pairing.rs` writes `[0x00, 0x09, 0x00]`)
167    /// and a concurrent inventory poll would otherwise drop.
168    pub async fn set_wireless_notifications(&self, enabled: bool) -> Result<(), ReceiverError> {
169        // Notification flags are a 3-byte big-endian word; the receiver-reporting
170        // bits live in byte 1 (WIRELESS = 0x000100, SOFTWARE_PRESENT = 0x000800).
171        const WIRELESS: u8 = 0x01;
172        let mut flags = self
173            .chan
174            .read_register(
175                RECEIVER_DEVICE_INDEX,
176                Register::Notifications.into(),
177                [0; 3],
178            )
179            .await?;
180        if enabled {
181            flags[1] |= WIRELESS;
182        } else {
183            flags[1] &= !WIRELESS;
184        }
185        self.chan
186            .write_register(RECEIVER_DEVICE_INDEX, Register::Notifications.into(), flags)
187            .await?;
188
189        Ok(())
190    }
191
192    /// Triggers device-arrival notifications for all currently connected
193    /// devices. Used to enumerate online devices at startup.
194    pub async fn trigger_device_arrival(&self) -> Result<(), ReceiverError> {
195        self.chan
196            .write_register(
197                RECEIVER_DEVICE_INDEX,
198                Register::Connections.into(),
199                [0x02, 0x00, 0x00],
200            )
201            .await?;
202
203        Ok(())
204    }
205
206    /// Provides general information about the receiver (serial number and
207    /// pairing slot count).
208    pub async fn get_receiver_info(&self) -> Result<ReceiverInfo, ReceiverError> {
209        let response = self
210            .chan
211            .read_long_register(
212                RECEIVER_DEVICE_INDEX,
213                Register::ReceiverInfo.into(),
214                [InfoSubRegister::ReceiverInfo.into(), 0, 0],
215            )
216            .await?;
217
218        Ok(ReceiverInfo {
219            serial_number: hex::encode_upper(&response[1..=4]),
220            pairing_slots: response[6],
221        })
222    }
223
224    /// Retrieves the pairing information for the device at `device_index`
225    /// (1-based slot number).
226    pub async fn get_device_pairing_information(
227        &self,
228        device_index: u8,
229    ) -> Result<DevicePairingInformation, ReceiverError> {
230        let response = self
231            .chan
232            .read_long_register(
233                RECEIVER_DEVICE_INDEX,
234                Register::ReceiverInfo.into(),
235                [
236                    u8::from(InfoSubRegister::DevicePairingInformation) | (device_index & 0x0f),
237                    0x00,
238                    0x00,
239                ],
240            )
241            .await?;
242
243        Ok(DevicePairingInformation {
244            wpid: u16::from_le_bytes([response[2], response[3]]),
245            // Kind is identity-only: an unrecognised nibble folds to
246            // `Unknown` instead of failing the whole pairing-info read.
247            kind: DeviceKind::from(response[1] & 0x0f),
248            encrypted: response[1] & (1 << 4) != 0,
249            online: response[1] & (1 << 6) == 0,
250            unit_id: [response[4], response[5], response[6], response[7]],
251        })
252    }
253
254    /// Provides the unique ID of the receiver (serial number).
255    pub async fn get_unique_id(&self) -> Result<String, ReceiverError> {
256        self.get_receiver_info().await.map(|i| i.serial_number)
257    }
258}
259
260/// The sub-id of the only notification this receiver emits: a paired device
261/// came online.
262const DEVICE_CONNECTION_SUB_ID: u8 = 0x41;
263
264/// Decodes an unsolicited receiver message into the event it carries, or
265/// `None` for a report this crate does not model.
266///
267/// Kept separate from the message listener in [`Receiver::new`] so the wire
268/// layout is reachable from tests without a HID channel behind it.
269fn decode_notification(msg: &v10::Message) -> Option<Event> {
270    let header = msg.header();
271    if header.sub_id != DEVICE_CONNECTION_SUB_ID {
272        return None;
273    }
274    let payload = msg.extend_payload();
275
276    // A connection notification is addressed to the device's own slot, which
277    // is the only place that index is reported.
278    Some(Event::DeviceConnection(DeviceConnection {
279        index: header.device_index,
280        // Kind is identity-only; an unrecognised nibble folds to `Unknown` —
281        // dropping the event would hide the device entirely, since arrival
282        // notifications are the only device source on this path.
283        kind: DeviceKind::from(payload[1] & 0x0f),
284        encrypted: payload[1] & (1 << 4) != 0,
285        online: payload[1] & (1 << 6) == 0,
286        wpid: u16::from_le_bytes([payload[2], payload[3]]),
287    }))
288}
289
290/// Represents some general information about a Unifying receiver.
291#[derive(Clone, PartialEq, Eq, Hash, Debug)]
292#[cfg_attr(feature = "serde", derive(serde::Serialize))]
293#[non_exhaustive]
294pub struct ReceiverInfo {
295    /// Receiver serial number.
296    pub serial_number: String,
297    /// Number of available pairing slots.
298    pub pairing_slots: u8,
299}
300
301/// Represents information about a paired device as read from the pairing
302/// register.
303#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
304#[cfg_attr(feature = "serde", derive(serde::Serialize))]
305#[non_exhaustive]
306pub struct DevicePairingInformation {
307    /// Wireless product ID of the paired device.
308    pub wpid: u16,
309    /// Device kind reported by the receiver.
310    pub kind: DeviceKind,
311    /// Whether the link is encrypted.
312    pub encrypted: bool,
313    /// Whether the device is currently online.
314    pub online: bool,
315    /// Device unit ID.
316    pub unit_id: [u8; 4],
317}
318
319/// Represents the kind of a device paired to a Unifying receiver.
320///
321/// The encoding matches Bolt for values 1–4; from 5 onwards Unifying uses a
322/// shifted table (Remote=5, Trackball=6, Touchpad=7) while Bolt reserves those
323/// values and places them at 7–9.
324#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)]
325#[cfg_attr(feature = "serde", derive(serde::Serialize))]
326#[non_exhaustive]
327#[repr(u8)]
328pub enum DeviceKind {
329    /// Unknown device kind — also the fold target for values this crate
330    /// does not model (kind is identity-only and must never drop an event).
331    #[num_enum(default)]
332    Unknown = 0x00,
333    /// Keyboard device.
334    Keyboard = 0x01,
335    /// Mouse device.
336    Mouse = 0x02,
337    /// Numeric keypad device.
338    Numpad = 0x03,
339    /// Presenter device.
340    Presenter = 0x04,
341    /// Remote-control device.
342    Remote = 0x05,
343    /// Trackball device.
344    Trackball = 0x06,
345    /// Touchpad device.
346    Touchpad = 0x07,
347}
348
349/// Represents a device-connection event fired by the receiver when a paired
350/// device comes online (or in response to [`Receiver::trigger_device_arrival`]).
351#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
352#[cfg_attr(feature = "serde", derive(serde::Serialize))]
353#[non_exhaustive]
354pub struct DeviceConnection {
355    /// Slot index (1-based) of the device.
356    pub index: u8,
357    /// Device kind reported by the receiver.
358    pub kind: DeviceKind,
359    /// Whether the link is encrypted.
360    pub encrypted: bool,
361    /// Whether the device is currently online.
362    pub online: bool,
363    /// Wireless product ID of the device.
364    pub wpid: u16,
365}
366
367/// Represents an event emitted by the Unifying receiver.
368#[derive(Clone, PartialEq, Eq, Hash, Debug)]
369#[cfg_attr(feature = "serde", derive(serde::Serialize))]
370#[non_exhaustive]
371pub enum Event {
372    /// Fired whenever a paired device connects or reconnects, and for all
373    /// online devices in response to [`Receiver::trigger_device_arrival`].
374    DeviceConnection(DeviceConnection),
375}
376
377#[cfg(test)]
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}