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(¬ification(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(¬ification(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(¬ification(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(¬ification(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(¬ification(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(¬ification(1, 0x40, [0u8; 17])), None);
480 assert_eq!(decode_notification(¬ification(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}