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};
14use openlogi_device_registry::receiver::{ReceiverProtocol, find_receiver};
15
16use crate::{
17    channel::{HidppChannel, MessageListenerGuard},
18    emitter::EventEmitter,
19    protocol::v10,
20    receiver::{RECEIVER_DEVICE_INDEX, ReceiverError},
21};
22
23/// All known registers of the Unifying receiver.
24#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize))]
26#[non_exhaustive]
27#[repr(u8)]
28pub enum Register {
29    /// Controls which notifications the receiver emits. Wireless device-arrival
30    /// (`0x41`) events are only re-broadcast while wireless notifications are
31    /// enabled here; see [`Receiver::set_wireless_notifications`].
32    Notifications = 0x00,
33
34    /// Enables or disables wireless device-connection notifications; also used
35    /// to read the pairing count and to trigger device-arrival events.
36    Connections = 0x02,
37
38    /// Provides information about the receiver and paired devices. It uses
39    /// sub-registers, as defined in [`InfoSubRegister`], to differentiate
40    /// between different kinds of information.
41    ReceiverInfo = 0xb5,
42}
43
44/// Represents the known sub-registers of the [`Register::ReceiverInfo`]
45/// register.
46#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize))]
48#[non_exhaustive]
49#[repr(u8)]
50pub enum InfoSubRegister {
51    /// Provides general information about the receiver (serial number, pairing
52    /// slot count).
53    ReceiverInfo = 0x03,
54
55    /// Provides information about a specific paired device. The device index
56    /// (4 bits) must be added to this base address to form the actual
57    /// sub-register: `0x50 | (device_index & 0x0f)`.
58    DevicePairingInformation = 0x50,
59
60    /// Provides the codename of a specific paired device. The device index (4
61    /// bits) must be added: `0x60 | (device_index & 0x0f)`.
62    ///
63    /// NOTE: `0x60` is the *Bolt* base. Wire-verified Unifying receivers store
64    /// names at base `0x40 + (n-1)` instead, so name reads go directly through
65    /// `read_codename_unifying` in `inventory.rs` rather than this constant —
66    /// don't reuse `DeviceCodename` for Unifying name reads.
67    DeviceCodename = 0x60,
68}
69
70/// Implements the Unifying wireless receiver.
71#[derive(Clone)]
72pub struct Receiver {
73    chan: Arc<HidppChannel>,
74    emitter: Arc<EventEmitter<Event>>,
75    _listener: Arc<MessageListenerGuard>,
76}
77
78impl Receiver {
79    /// Tries to initialize a new [`Receiver`] from a raw HID++ channel.
80    ///
81    /// Returns [`ReceiverError::UnknownReceiver`] when the channel's VID/PID
82    /// doesn't match any known Unifying receiver.
83    pub fn new(chan: Arc<HidppChannel>) -> Result<Self, ReceiverError> {
84        if find_receiver(chan.vendor_id, chan.product_id)
85            .is_none_or(|receiver| receiver.protocol != ReceiverProtocol::Unifying)
86        {
87            return Err(ReceiverError::UnknownReceiver);
88        }
89
90        let emitter = Arc::new(EventEmitter::new());
91
92        let listener = chan.add_msg_listener_guarded({
93            let emitter = Arc::clone(&emitter);
94            move |raw, matched| {
95                // A report already matched to an outgoing request is a
96                // response, not a notification.
97                if matched {
98                    return;
99                }
100
101                if let Some(event) = decode_notification(&v10::Message::from(raw)) {
102                    emitter.emit(event);
103                }
104            }
105        });
106
107        Ok(Receiver {
108            _listener: Arc::new(listener),
109            chan,
110            emitter,
111        })
112    }
113
114    /// Creates a new listener for receiving receiver events.
115    #[must_use]
116    pub fn listen(&self) -> async_channel::Receiver<Event> {
117        self.emitter.create_receiver()
118    }
119
120    /// Counts the number of devices currently paired to this receiver.
121    /// Offline (sleeping) devices are included since pairings are persistent.
122    pub async fn count_pairings(&self) -> Result<u8, ReceiverError> {
123        let response = self
124            .chan
125            .read_register(
126                RECEIVER_DEVICE_INDEX,
127                Register::Connections.into(),
128                [0u8; 3],
129            )
130            .await?;
131
132        Ok(response[1])
133    }
134
135    /// Enables or disables wireless device-connection notifications.
136    ///
137    /// The receiver only re-broadcasts `0x41` device-arrival events (the source
138    /// for [`Self::trigger_device_arrival`]) while this is on. With it off the
139    /// trigger write is ACK'd but emits nothing — which is why a paired, online
140    /// device can fail to enumerate. Solaar enables this before listing.
141    ///
142    /// Read-modify-write of just the `WIRELESS` bit so it can't clobber other
143    /// flags already set on register `0x00` — notably `SOFTWARE_PRESENT` (0x08),
144    /// which the pairing flow enables (`pairing.rs` writes `[0x00, 0x09, 0x00]`)
145    /// and a concurrent inventory poll would otherwise drop.
146    pub async fn set_wireless_notifications(&self, enabled: bool) -> Result<(), ReceiverError> {
147        // Notification flags are a 3-byte big-endian word; the receiver-reporting
148        // bits live in byte 1 (WIRELESS = 0x000100, SOFTWARE_PRESENT = 0x000800).
149        let mut flags = self
150            .chan
151            .read_register(
152                RECEIVER_DEVICE_INDEX,
153                Register::Notifications.into(),
154                [0; 3],
155            )
156            .await?;
157        // This flag persists in receiver RAM. Avoid issuing an identical
158        // register write on every inventory tick: Lightspeed receiver c54d
159        // has been observed to occasionally omit the ACK for that no-op,
160        // parking the otherwise healthy shared channel until timeout.
161        if !update_wireless_notification_flag(&mut flags, enabled) {
162            return Ok(());
163        }
164        self.chan
165            .write_register(RECEIVER_DEVICE_INDEX, Register::Notifications.into(), flags)
166            .await?;
167
168        Ok(())
169    }
170
171    /// Triggers device-arrival notifications for every paired slot, online or
172    /// not — the notification's link-status bit distinguishes (Solaar uses the
173    /// same trigger as its "scan all devices" pass). Used to enumerate paired
174    /// devices at startup.
175    pub async fn trigger_device_arrival(&self) -> Result<(), ReceiverError> {
176        self.chan
177            .write_register(
178                RECEIVER_DEVICE_INDEX,
179                Register::Connections.into(),
180                [0x02, 0x00, 0x00],
181            )
182            .await?;
183
184        Ok(())
185    }
186
187    /// Provides general information about the receiver (serial number and
188    /// pairing slot count).
189    pub async fn get_receiver_info(&self) -> Result<ReceiverInfo, ReceiverError> {
190        let response = self
191            .chan
192            .read_long_register(
193                RECEIVER_DEVICE_INDEX,
194                Register::ReceiverInfo.into(),
195                [InfoSubRegister::ReceiverInfo.into(), 0, 0],
196            )
197            .await?;
198
199        Ok(ReceiverInfo {
200            serial_number: hex::encode_upper(&response[1..=4]),
201            pairing_slots: response[6],
202        })
203    }
204
205    /// Retrieves the pairing information for the device at `device_index`
206    /// (1-based slot number).
207    pub async fn get_device_pairing_information(
208        &self,
209        device_index: u8,
210    ) -> Result<DevicePairingInformation, ReceiverError> {
211        let response = self
212            .chan
213            .read_long_register(
214                RECEIVER_DEVICE_INDEX,
215                Register::ReceiverInfo.into(),
216                [
217                    u8::from(InfoSubRegister::DevicePairingInformation) | (device_index & 0x0f),
218                    0x00,
219                    0x00,
220                ],
221            )
222            .await?;
223
224        Ok(DevicePairingInformation {
225            wpid: u16::from_le_bytes([response[2], response[3]]),
226            // Kind is identity-only: an unrecognised nibble folds to
227            // `Unknown` instead of failing the whole pairing-info read.
228            kind: DeviceKind::from(response[1] & 0x0f),
229            encrypted: response[1] & (1 << 5) != 0,
230            online: response[1] & (1 << 6) == 0,
231            unit_id: [response[4], response[5], response[6], response[7]],
232        })
233    }
234
235    /// Provides the unique ID of the receiver (serial number).
236    pub async fn get_unique_id(&self) -> Result<String, ReceiverError> {
237        self.get_receiver_info().await.map(|i| i.serial_number)
238    }
239}
240
241/// Update the wireless-notification bit and report whether a register write is
242/// needed. Kept separate so the preservation of unrelated flags is testable
243/// without a receiver transport.
244fn update_wireless_notification_flag(flags: &mut [u8; 3], enabled: bool) -> bool {
245    const WIRELESS: u8 = 0x01;
246    let previous = *flags;
247    if enabled {
248        flags[1] |= WIRELESS;
249    } else {
250        flags[1] &= !WIRELESS;
251    }
252    *flags != previous
253}
254
255/// The sub-id of the only notification this receiver emits: a paired slot's
256/// connection status changed, or was re-reported by
257/// [`Receiver::trigger_device_arrival`].
258const DEVICE_CONNECTION_SUB_ID: u8 = 0x41;
259
260/// Decodes an unsolicited receiver message into the event it carries, or
261/// `None` for a report this crate does not model.
262///
263/// Public so consumers can decode captured reports and fabricate events from
264/// wire bytes in their own tests without a HID channel behind them.
265#[must_use]
266pub fn decode_notification(msg: &v10::Message) -> Option<Event> {
267    let header = msg.header();
268    if header.sub_id != DEVICE_CONNECTION_SUB_ID {
269        return None;
270    }
271    let payload = msg.extend_payload();
272
273    // A connection notification is addressed to the device's own slot, which
274    // is the only place that index is reported.
275    Some(Event::DeviceConnection(DeviceConnection {
276        index: header.device_index,
277        // Kind is identity-only; an unrecognised nibble folds to `Unknown` —
278        // dropping the event would hide the device entirely, since arrival
279        // notifications are the only device source on this path.
280        kind: DeviceKind::from(payload[1] & 0x0f),
281        // Device-info high nibble: bit 6 = link not established, bit 5 = link
282        // encrypted, bit 4 = software present (same layout as Bolt; Solaar
283        // decodes both receivers with one mask table).
284        encrypted: payload[1] & (1 << 5) != 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's link status changes, or re-broadcast for a paired slot in
351/// response to [`Receiver::trigger_device_arrival`].
352#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
353#[cfg_attr(feature = "serde", derive(serde::Serialize))]
354#[non_exhaustive]
355pub struct DeviceConnection {
356    /// Slot index (1-based) of the device.
357    pub index: u8,
358    /// Device kind reported by the receiver.
359    pub kind: DeviceKind,
360    /// Whether the link is encrypted.
361    pub encrypted: bool,
362    /// Whether the device's link is currently established (payload bit 6
363    /// clear). Trigger-driven re-broadcasts report offline paired slots with
364    /// `false`, so a `0x41` alone is a slot report, not proof of liveness.
365    pub online: bool,
366    /// Wireless product ID of the device.
367    pub wpid: u16,
368}
369
370/// Represents an event emitted by the Unifying receiver.
371#[derive(Clone, PartialEq, Eq, Hash, Debug)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize))]
373#[non_exhaustive]
374pub enum Event {
375    /// Fired whenever a paired device connects or reconnects, and for *every*
376    /// paired slot — offline ones included — in response to
377    /// [`Receiver::trigger_device_arrival`], with
378    /// [`DeviceConnection::online`] carrying the link status.
379    DeviceConnection(DeviceConnection),
380}
381
382#[cfg(test)]
383mod tests {
384    use std::sync::Arc;
385
386    use super::{
387        DeviceConnection, DeviceKind, DevicePairingInformation, Event, InfoSubRegister, Receiver,
388        Register, decode_notification, update_wireless_notification_flag,
389    };
390    use crate::channel::tests::{MockRawHidChannel, channel_with_reader};
391    use crate::protocol::v10::{Message, MessageHeader, MessageType};
392
393    /// Builds the long notification the receiver broadcasts, with `payload`
394    /// laid out exactly as the 17 bytes following the header.
395    fn notification(device_index: u8, sub_id: u8, payload: [u8; 17]) -> Message {
396        Message::Long(
397            MessageHeader {
398                device_index,
399                sub_id,
400            },
401            payload,
402        )
403    }
404
405    #[test]
406    fn wireless_notification_flag_only_writes_on_a_real_change() {
407        let mut disabled = [0x00, 0x08, 0x55];
408        assert!(update_wireless_notification_flag(&mut disabled, true));
409        assert_eq!(disabled, [0x00, 0x09, 0x55]);
410        assert!(!update_wireless_notification_flag(&mut disabled, true));
411
412        assert!(update_wireless_notification_flag(&mut disabled, false));
413        assert_eq!(disabled, [0x00, 0x08, 0x55]);
414        assert!(!update_wireless_notification_flag(&mut disabled, false));
415    }
416
417    #[test]
418    fn device_connection_reads_the_slot_from_the_header() {
419        // The header byte is the only place the slot is reported.
420        let mut payload = [0u8; 17];
421        payload[1] = 0x02; // mouse, not encrypted, online
422        payload[2] = 0x74;
423        payload[3] = 0x40;
424
425        assert_eq!(
426            decode_notification(&notification(5, 0x41, payload)).unwrap(),
427            Event::DeviceConnection(DeviceConnection {
428                index: 5,
429                kind: DeviceKind::Mouse,
430                encrypted: false,
431                online: true,
432                wpid: 0x4074,
433            })
434        );
435    }
436
437    #[test]
438    fn encryption_sits_on_bit_5_and_bit_4_is_software_present() {
439        // Both receivers report link encryption on bit 5 of the device-info
440        // byte; bit 4 is the software-present flag. This decoder used to read
441        // bit 4, reporting "Options+ seen" as link encryption.
442        let connection = |status: u8| {
443            let mut payload = [0u8; 17];
444            payload[1] = status;
445            match decode_notification(&notification(1, 0x41, payload)) {
446                Some(Event::DeviceConnection(connection)) => connection,
447                other => panic!("expected a device connection, got {other:?}"),
448            }
449        };
450
451        assert!(connection(1 << 5).encrypted);
452        assert!(!connection(1 << 4).encrypted);
453    }
454
455    #[test]
456    fn pairing_information_reads_encryption_from_bit_5() {
457        // The pairing register carries the same device-info byte as the 0x41
458        // notification, decoded independently — pin its bits too so the two
459        // paths cannot diverge unnoticed.
460        futures::executor::block_on(async {
461            let (raw, handle) = MockRawHidChannel::new();
462            let chan = Arc::new(channel_with_reader(raw).await);
463            let receiver =
464                Receiver::new(chan).expect("the mock's VID/PID routes as a Unifying receiver");
465
466            // First response: bit 5 + bit 6 — encrypted, offline. Second:
467            // bit 4 only (software present), which must not read as
468            // encryption.
469            for status in [0x62, 0x12] {
470                let mut payload = [0u8; 17];
471                payload[0] = Register::ReceiverInfo.into(); // RAP matches on the address echo
472                payload[1] = u8::from(InfoSubRegister::DevicePairingInformation) | 0x02;
473                payload[2] = status;
474                payload[3] = 0x69; // wpid, little-endian
475                payload[4] = 0x40;
476                payload[5..9].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
477                handle.queue_response(
478                    Message::Long(
479                        MessageHeader {
480                            device_index: super::RECEIVER_DEVICE_INDEX,
481                            sub_id: MessageType::GetLongRegister.into(),
482                        },
483                        payload,
484                    )
485                    .into(),
486                );
487            }
488
489            let encrypted_offline = receiver.get_device_pairing_information(2).await.unwrap();
490            assert_eq!(
491                encrypted_offline,
492                DevicePairingInformation {
493                    wpid: 0x4069,
494                    kind: DeviceKind::Mouse,
495                    encrypted: true,
496                    online: false,
497                    unit_id: [0xde, 0xad, 0xbe, 0xef],
498                }
499            );
500
501            let software_present = receiver.get_device_pairing_information(2).await.unwrap();
502            assert!(
503                !software_present.encrypted,
504                "bit 4 is software-present, not encryption"
505            );
506            assert!(software_present.online);
507        });
508    }
509
510    #[test]
511    fn bit_6_is_set_when_the_device_is_offline() {
512        let mut payload = [0u8; 17];
513        payload[1] = 1 << 6;
514
515        let Some(Event::DeviceConnection(connection)) =
516            decode_notification(&notification(1, 0x41, payload))
517        else {
518            panic!("expected a device connection");
519        };
520        assert!(!connection.online);
521    }
522
523    #[test]
524    fn device_kind_uses_the_unifying_table_not_bolts() {
525        // Unifying and Bolt agree up to 4 and diverge from 5 on: `5` is a
526        // remote here but reserved on Bolt, which places its remote at 7.
527        let kind = |nibble: u8| {
528            let mut payload = [0u8; 17];
529            payload[1] = nibble;
530            match decode_notification(&notification(1, 0x41, payload)) {
531                Some(Event::DeviceConnection(connection)) => connection.kind,
532                other => panic!("expected a device connection, got {other:?}"),
533            }
534        };
535
536        assert_eq!(kind(0x05), DeviceKind::Remote);
537        assert_eq!(kind(0x06), DeviceKind::Trackball);
538        assert_eq!(kind(0x07), DeviceKind::Touchpad);
539    }
540
541    #[test]
542    fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
543        // Losing the event would hide the device from enumeration entirely,
544        // and arrival notifications are the only device source on this path.
545        let mut payload = [0u8; 17];
546        payload[1] = 0x0d;
547
548        let Some(Event::DeviceConnection(connection)) =
549            decode_notification(&notification(1, 0x41, payload))
550        else {
551            panic!("an unknown kind must still produce an event");
552        };
553        assert_eq!(connection.kind, DeviceKind::Unknown);
554    }
555
556    #[test]
557    fn other_sub_ids_are_dropped() {
558        assert_eq!(decode_notification(&notification(1, 0x40, [0u8; 17])), None);
559        assert_eq!(decode_notification(&notification(1, 0x4f, [0u8; 17])), None);
560    }
561
562    #[test]
563    fn short_notifications_decode_from_the_zero_padded_payload() {
564        let short = Message::Short(
565            MessageHeader {
566                device_index: 2,
567                sub_id: 0x41,
568            },
569            [0x00, 0x01, 0x74, 0x40],
570        );
571
572        assert_eq!(
573            decode_notification(&short).unwrap(),
574            Event::DeviceConnection(DeviceConnection {
575                index: 2,
576                kind: DeviceKind::Keyboard,
577                encrypted: false,
578                online: true,
579                wpid: 0x4074,
580            })
581        );
582    }
583}