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 /// `inventory::probe::unifying::read_codename` 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_sub_register(
193 RECEIVER_DEVICE_INDEX,
194 Register::ReceiverInfo.into(),
195 InfoSubRegister::ReceiverInfo.into(),
196 [0, 0],
197 )
198 .await?;
199
200 Ok(ReceiverInfo {
201 serial_number: hex::encode_upper(&response[1..=4]),
202 pairing_slots: response[6],
203 })
204 }
205
206 /// Retrieves the pairing information for the device at `device_index`
207 /// (1-based slot number).
208 pub async fn get_device_pairing_information(
209 &self,
210 device_index: u8,
211 ) -> Result<DevicePairingInformation, ReceiverError> {
212 let response = self
213 .chan
214 .read_long_sub_register(
215 RECEIVER_DEVICE_INDEX,
216 Register::ReceiverInfo.into(),
217 u8::from(InfoSubRegister::DevicePairingInformation) | (device_index & 0x0f),
218 [0x00, 0x00],
219 )
220 .await?;
221
222 Ok(DevicePairingInformation {
223 wpid: u16::from_le_bytes([response[2], response[3]]),
224 // Kind is identity-only: an unrecognised nibble folds to
225 // `Unknown` instead of failing the whole pairing-info read.
226 kind: DeviceKind::from(response[1] & 0x0f),
227 encrypted: response[1] & (1 << 5) != 0,
228 online: response[1] & (1 << 6) == 0,
229 unit_id: [response[4], response[5], response[6], response[7]],
230 })
231 }
232
233 /// Provides the unique ID of the receiver (serial number).
234 pub async fn get_unique_id(&self) -> Result<String, ReceiverError> {
235 self.get_receiver_info().await.map(|i| i.serial_number)
236 }
237}
238
239/// Update the wireless-notification bit and report whether a register write is
240/// needed. Kept separate so the preservation of unrelated flags is testable
241/// without a receiver transport.
242fn update_wireless_notification_flag(flags: &mut [u8; 3], enabled: bool) -> bool {
243 const WIRELESS: u8 = 0x01;
244 let previous = *flags;
245 if enabled {
246 flags[1] |= WIRELESS;
247 } else {
248 flags[1] &= !WIRELESS;
249 }
250 *flags != previous
251}
252
253/// The sub-id of the only notification this receiver emits: a paired slot's
254/// connection status changed, or was re-reported by
255/// [`Receiver::trigger_device_arrival`].
256const DEVICE_CONNECTION_SUB_ID: u8 = 0x41;
257
258/// Decodes an unsolicited receiver message into the event it carries, or
259/// `None` for a report this crate does not model.
260///
261/// Public so consumers can decode captured reports and fabricate events from
262/// wire bytes in their own tests without a HID channel behind them.
263#[must_use]
264pub fn 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 // Device-info high nibble: bit 6 = link not established, bit 5 = link
280 // encrypted, bit 4 = software present (same layout as Bolt; Solaar
281 // decodes both receivers with one mask table).
282 encrypted: payload[1] & (1 << 5) != 0,
283 online: payload[1] & (1 << 6) == 0,
284 wpid: u16::from_le_bytes([payload[2], payload[3]]),
285 }))
286}
287
288/// Represents some general information about a Unifying receiver.
289#[derive(Clone, PartialEq, Eq, Hash, Debug)]
290#[cfg_attr(feature = "serde", derive(serde::Serialize))]
291#[non_exhaustive]
292pub struct ReceiverInfo {
293 /// Receiver serial number.
294 pub serial_number: String,
295 /// Number of available pairing slots.
296 pub pairing_slots: u8,
297}
298
299/// Represents information about a paired device as read from the pairing
300/// register.
301#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
302#[cfg_attr(feature = "serde", derive(serde::Serialize))]
303#[non_exhaustive]
304pub struct DevicePairingInformation {
305 /// Wireless product ID of the paired device.
306 pub wpid: u16,
307 /// Device kind reported by the receiver.
308 pub kind: DeviceKind,
309 /// Whether the link is encrypted.
310 pub encrypted: bool,
311 /// Whether the device is currently online.
312 pub online: bool,
313 /// Device unit ID.
314 pub unit_id: [u8; 4],
315}
316
317/// Represents the kind of a device paired to a Unifying receiver.
318///
319/// The encoding matches Bolt for values 1–4; from 5 onwards Unifying uses a
320/// shifted table (Remote=5, Trackball=6, Touchpad=7) while Bolt reserves those
321/// values and places them at 7–9.
322#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, FromPrimitive)]
323#[cfg_attr(feature = "serde", derive(serde::Serialize))]
324#[non_exhaustive]
325#[repr(u8)]
326pub enum DeviceKind {
327 /// Unknown device kind — also the fold target for values this crate
328 /// does not model (kind is identity-only and must never drop an event).
329 #[num_enum(default)]
330 Unknown = 0x00,
331 /// Keyboard device.
332 Keyboard = 0x01,
333 /// Mouse device.
334 Mouse = 0x02,
335 /// Numeric keypad device.
336 Numpad = 0x03,
337 /// Presenter device.
338 Presenter = 0x04,
339 /// Remote-control device.
340 Remote = 0x05,
341 /// Trackball device.
342 Trackball = 0x06,
343 /// Touchpad device.
344 Touchpad = 0x07,
345}
346
347/// Represents a device-connection event fired by the receiver when a paired
348/// device's link status changes, or re-broadcast for a paired slot in
349/// response to [`Receiver::trigger_device_arrival`].
350#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
351#[cfg_attr(feature = "serde", derive(serde::Serialize))]
352#[non_exhaustive]
353pub struct DeviceConnection {
354 /// Slot index (1-based) of the device.
355 pub index: u8,
356 /// Device kind reported by the receiver.
357 pub kind: DeviceKind,
358 /// Whether the link is encrypted.
359 pub encrypted: bool,
360 /// Whether the device's link is currently established (payload bit 6
361 /// clear). Trigger-driven re-broadcasts report offline paired slots with
362 /// `false`, so a `0x41` alone is a slot report, not proof of liveness.
363 pub online: bool,
364 /// Wireless product ID of the device.
365 pub wpid: u16,
366}
367
368/// Represents an event emitted by the Unifying receiver.
369#[derive(Clone, PartialEq, Eq, Hash, Debug)]
370#[cfg_attr(feature = "serde", derive(serde::Serialize))]
371#[non_exhaustive]
372pub enum Event {
373 /// Fired whenever a paired device connects or reconnects, and for *every*
374 /// paired slot — offline ones included — in response to
375 /// [`Receiver::trigger_device_arrival`], with
376 /// [`DeviceConnection::online`] carrying the link status.
377 DeviceConnection(DeviceConnection),
378}
379
380#[cfg(test)]
381mod tests {
382 use std::sync::Arc;
383
384 use super::{
385 DeviceConnection, DeviceKind, DevicePairingInformation, Event, InfoSubRegister, Receiver,
386 Register, decode_notification, update_wireless_notification_flag,
387 };
388 use crate::channel::mock::{MockRawHidChannel, channel_with_reader};
389 use crate::protocol::v10::{Message, MessageHeader, MessageType};
390
391 /// Builds the long notification the receiver broadcasts, with `payload`
392 /// laid out exactly as the 17 bytes following the header.
393 fn notification(device_index: u8, sub_id: u8, payload: [u8; 17]) -> Message {
394 Message::Long(
395 MessageHeader {
396 device_index,
397 sub_id,
398 },
399 payload,
400 )
401 }
402
403 #[test]
404 fn wireless_notification_flag_only_writes_on_a_real_change() {
405 let mut disabled = [0x00, 0x08, 0x55];
406 assert!(update_wireless_notification_flag(&mut disabled, true));
407 assert_eq!(disabled, [0x00, 0x09, 0x55]);
408 assert!(!update_wireless_notification_flag(&mut disabled, true));
409
410 assert!(update_wireless_notification_flag(&mut disabled, false));
411 assert_eq!(disabled, [0x00, 0x08, 0x55]);
412 assert!(!update_wireless_notification_flag(&mut disabled, false));
413 }
414
415 #[test]
416 fn device_connection_reads_the_slot_from_the_header() {
417 // The header byte is the only place the slot is reported.
418 let mut payload = [0u8; 17];
419 payload[1] = 0x02; // mouse, not encrypted, online
420 payload[2] = 0x74;
421 payload[3] = 0x40;
422
423 assert_eq!(
424 decode_notification(¬ification(5, 0x41, payload)).unwrap(),
425 Event::DeviceConnection(DeviceConnection {
426 index: 5,
427 kind: DeviceKind::Mouse,
428 encrypted: false,
429 online: true,
430 wpid: 0x4074,
431 })
432 );
433 }
434
435 #[test]
436 fn encryption_sits_on_bit_5_and_bit_4_is_software_present() {
437 // Both receivers report link encryption on bit 5 of the device-info
438 // byte; bit 4 is the software-present flag. This decoder used to read
439 // bit 4, reporting "Options+ seen" as link encryption.
440 let connection = |status: u8| {
441 let mut payload = [0u8; 17];
442 payload[1] = status;
443 match decode_notification(¬ification(1, 0x41, payload)) {
444 Some(Event::DeviceConnection(connection)) => connection,
445 other => panic!("expected a device connection, got {other:?}"),
446 }
447 };
448
449 assert!(connection(1 << 5).encrypted);
450 assert!(!connection(1 << 4).encrypted);
451 }
452
453 #[test]
454 fn pairing_information_reads_encryption_from_bit_5() {
455 // The pairing register carries the same device-info byte as the 0x41
456 // notification, decoded independently — pin its bits too so the two
457 // paths cannot diverge unnoticed.
458 futures::executor::block_on(async {
459 let (raw, handle) = MockRawHidChannel::new();
460 let chan = Arc::new(channel_with_reader(raw).await);
461 let receiver =
462 Receiver::new(chan).expect("the mock's VID/PID routes as a Unifying receiver");
463
464 // First response: bit 5 + bit 6 — encrypted, offline. Second:
465 // bit 4 only (software present), which must not read as
466 // encryption.
467 for status in [0x62, 0x12] {
468 let mut payload = [0u8; 17];
469 payload[0] = Register::ReceiverInfo.into(); // RAP matches on the address echo
470 payload[1] = u8::from(InfoSubRegister::DevicePairingInformation) | 0x02;
471 payload[2] = status;
472 payload[3] = 0x69; // wpid, little-endian
473 payload[4] = 0x40;
474 payload[5..9].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]);
475 handle.queue_response(
476 Message::Long(
477 MessageHeader {
478 device_index: super::RECEIVER_DEVICE_INDEX,
479 sub_id: MessageType::GetLongRegister.into(),
480 },
481 payload,
482 )
483 .into(),
484 );
485 }
486
487 let encrypted_offline = receiver.get_device_pairing_information(2).await.unwrap();
488 assert_eq!(
489 encrypted_offline,
490 DevicePairingInformation {
491 wpid: 0x4069,
492 kind: DeviceKind::Mouse,
493 encrypted: true,
494 online: false,
495 unit_id: [0xde, 0xad, 0xbe, 0xef],
496 }
497 );
498
499 let software_present = receiver.get_device_pairing_information(2).await.unwrap();
500 assert!(
501 !software_present.encrypted,
502 "bit 4 is software-present, not encryption"
503 );
504 assert!(software_present.online);
505 });
506 }
507
508 #[test]
509 fn bit_6_is_set_when_the_device_is_offline() {
510 let mut payload = [0u8; 17];
511 payload[1] = 1 << 6;
512
513 let Some(Event::DeviceConnection(connection)) =
514 decode_notification(¬ification(1, 0x41, payload))
515 else {
516 panic!("expected a device connection");
517 };
518 assert!(!connection.online);
519 }
520
521 #[test]
522 fn device_kind_uses_the_unifying_table_not_bolts() {
523 // Unifying and Bolt agree up to 4 and diverge from 5 on: `5` is a
524 // remote here but reserved on Bolt, which places its remote at 7.
525 let kind = |nibble: u8| {
526 let mut payload = [0u8; 17];
527 payload[1] = nibble;
528 match decode_notification(¬ification(1, 0x41, payload)) {
529 Some(Event::DeviceConnection(connection)) => connection.kind,
530 other => panic!("expected a device connection, got {other:?}"),
531 }
532 };
533
534 assert_eq!(kind(0x05), DeviceKind::Remote);
535 assert_eq!(kind(0x06), DeviceKind::Trackball);
536 assert_eq!(kind(0x07), DeviceKind::Touchpad);
537 }
538
539 #[test]
540 fn unmodelled_device_kind_folds_to_unknown_instead_of_dropping_the_event() {
541 // Losing the event would hide the device from enumeration entirely,
542 // and arrival notifications are the only device source on this path.
543 let mut payload = [0u8; 17];
544 payload[1] = 0x0d;
545
546 let Some(Event::DeviceConnection(connection)) =
547 decode_notification(¬ification(1, 0x41, payload))
548 else {
549 panic!("an unknown kind must still produce an event");
550 };
551 assert_eq!(connection.kind, DeviceKind::Unknown);
552 }
553
554 #[test]
555 fn other_sub_ids_are_dropped() {
556 assert_eq!(decode_notification(¬ification(1, 0x40, [0u8; 17])), None);
557 assert_eq!(decode_notification(¬ification(1, 0x4f, [0u8; 17])), None);
558 }
559
560 #[test]
561 fn short_notifications_decode_from_the_zero_padded_payload() {
562 let short = Message::Short(
563 MessageHeader {
564 device_index: 2,
565 sub_id: 0x41,
566 },
567 [0x00, 0x01, 0x74, 0x40],
568 );
569
570 assert_eq!(
571 decode_notification(&short).unwrap(),
572 Event::DeviceConnection(DeviceConnection {
573 index: 2,
574 kind: DeviceKind::Keyboard,
575 encrypted: false,
576 online: true,
577 wpid: 0x4074,
578 })
579 );
580 }
581}