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