Skip to main content

openlogi_device/inventory/
events.rs

1//! Coalesced lifecycle events from inventory-owned HID++ channels.
2//!
3//! The persistent [`super::Enumerator`] opens each OS HID node once. This
4//! module attaches one message listener to that existing channel and keeps
5//! only the feature indexes needed to recognize lifecycle notifications. The
6//! events never mutate inventory: they request another authoritative
7//! reconciliation from the enumerator.
8//!
9//! A broadcast that announces a change to a device *setting* — Fn lock
10//! toggled on the keyboard, the DPI changed by a mouse button — also bumps
11//! that device's settings revision here, which the next reconciliation
12//! publishes as [`PairedDevice::settings_revision`]. A client re-reads the
13//! device's settings when it sees the revision move.
14//!
15//! [`PairedDevice::settings_revision`]: openlogi_core::device::PairedDevice::settings_revision
16
17use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::{Arc, PoisonError, RwLock};
19
20use hidpp::channel::{HidppChannel, HidppMessage, MessageListenerGuard};
21use hidpp::feature::adc_measurement::AdcMeasurementFeature;
22use hidpp::feature::backlight::BacklightFeature;
23use hidpp::feature::battery_status::BatteryStatusFeature;
24use hidpp::feature::battery_voltage::BatteryVoltageFeature;
25use hidpp::feature::extended_dpi::ExtendedDpiFeature;
26use hidpp::feature::extended_report_rate::ExtendedReportRateFeature;
27use hidpp::feature::fn_inversion::{
28    FnInversionMultiHostFeature, FnInversionWithDefaultStateFeature,
29};
30use hidpp::feature::multi_platform::PlatformChange;
31use hidpp::feature::unified_battery::BatteryEvent;
32use hidpp::feature::wireless_device_status::WirelessDeviceStatusEvent;
33use hidpp::protocol::{v10, v20};
34use hidpp::receiver::{bolt, unifying};
35use serde::{Deserialize, Serialize};
36#[cfg(test)]
37use tokio::sync::Notify;
38use tokio::sync::mpsc;
39
40use crate::ReceiverProtocol;
41
42/// A HID++ lifecycle source that requested authoritative inventory
43/// reconciliation.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum HidppEventSource {
46    /// A receiver reported a paired slot connecting or disconnecting.
47    ReceiverConnection,
48    /// A device's `WirelessDeviceStatus` feature reported reconnection.
49    WirelessDeviceStatus,
50    /// A device's event-capable `UnifiedBattery` feature reported a change.
51    UnifiedBattery,
52    /// A device's legacy `BatteryStatus` (`0x1000`) feature broadcast a new
53    /// level or charging status.
54    BatteryStatus,
55    /// A device's `BatteryVoltage` (`0x1001`) feature broadcast a new voltage
56    /// or charging state.
57    BatteryVoltage,
58    /// A device's `AdcMeasurement` feature broadcast a reading or a link
59    /// change — a headset dongle's headset switching on or off.
60    AdcMeasurement,
61    /// A keyboard's Fn-inversion feature (`0x40a2` or `0x40a3`) reported its
62    /// Fn lock changing on the device, as Fn+Esc does.
63    FnInversion,
64    /// A mouse's `ExtendedAdjustableDpi` (`0x2202`) feature reported its
65    /// sensor DPI changing on the device, as a DPI button does.
66    ExtendedDpi,
67    /// A keyboard's `MultiPlatform` (`0x4531`) feature reported its key
68    /// layout platform changing on the device, as a platform key combination
69    /// does.
70    MultiPlatform,
71    /// A mouse's `ExtendedAdjustableReportRate` (`0x8061`) feature reported
72    /// its report rate changing on the device.
73    ExtendedReportRate,
74    /// A keyboard's `Backlight` (`0x1982`) feature reported its backlight
75    /// changing on the device — the backlight keys, the ambient-light
76    /// sensor, or the battery.
77    Backlight,
78}
79
80impl HidppEventSource {
81    /// Whether this source announces a change to a device setting a client
82    /// shows, rather than to the device's presence or battery.
83    const fn changes_settings(self) -> bool {
84        matches!(
85            self,
86            Self::FnInversion
87                | Self::ExtendedDpi
88                | Self::MultiPlatform
89                | Self::ExtendedReportRate
90                | Self::Backlight
91        )
92    }
93}
94
95/// The sending half of the bounded HID++ reconciliation-request channel.
96///
97/// Clones are installed only on inventory-owned channels. Capacity is one:
98/// every source asks for the same full reconciliation, so a burst has no
99/// additional meaning and must not grow memory while a slow probe is running.
100#[derive(Clone)]
101pub struct EventNotifier {
102    sender: mpsc::Sender<HidppEventSource>,
103    #[cfg(test)]
104    observation: Option<Arc<EventObservation>>,
105}
106
107impl EventNotifier {
108    fn notify(&self, source: HidppEventSource) {
109        let _ = self.sender.try_send(source);
110        #[cfg(test)]
111        if let Some(observation) = &self.observation {
112            observation.count.fetch_add(1, Ordering::Release);
113            observation.changed.notify_waiters();
114        }
115    }
116}
117
118/// The receiving half of the coalesced HID++ reconciliation-request channel.
119pub type EventReceiver = mpsc::Receiver<HidppEventSource>;
120
121/// Build the bounded channel used by an inventory watcher and its enumerator.
122#[must_use]
123pub fn event_channel() -> (EventNotifier, EventReceiver) {
124    let (sender, receiver) = mpsc::channel(1);
125    (
126        EventNotifier {
127            sender,
128            #[cfg(test)]
129            observation: None,
130        },
131        receiver,
132    )
133}
134
135#[cfg(test)]
136struct EventObservation {
137    count: AtomicUsize,
138    changed: Notify,
139}
140
141/// Barrier observing decoded lifecycle events, including coalesced requests.
142#[cfg(test)]
143pub(crate) struct EventObserver {
144    observation: Arc<EventObservation>,
145}
146
147#[cfg(test)]
148impl EventObserver {
149    pub(crate) async fn wait_for(&self, expected: usize) {
150        loop {
151            let changed = self.observation.changed.notified();
152            if self.observation.count.load(Ordering::Acquire) >= expected {
153                return;
154            }
155            changed.await;
156        }
157    }
158}
159
160/// Build the production capacity-one event channel plus a decode observation barrier.
161#[cfg(test)]
162pub(crate) fn observed_event_channel() -> (EventNotifier, EventReceiver, EventObserver) {
163    let (sender, receiver) = mpsc::channel(1);
164    let observation = Arc::new(EventObservation {
165        count: AtomicUsize::new(0),
166        changed: Notify::new(),
167    });
168    (
169        EventNotifier {
170            sender,
171            observation: Some(Arc::clone(&observation)),
172        },
173        receiver,
174        EventObserver { observation },
175    )
176}
177
178/// Runtime feature indexes whose unsolicited events affect inventory.
179///
180/// Stored with the immutable feature-table cache because these indexes are
181/// discovered by the same walk. Every battery source the probe reads —
182/// unified `0x1004`, legacy `0x1000`, voltage `0x1001` and `0x1F20` ADC
183/// measurement — broadcasts its changes, so each has an index here. So do the
184/// settings a user can change on the device itself: Fn lock (`0x40a2`,
185/// `0x40a3`), extended DPI (`0x2202`), the keyboard platform (`0x4531`), the
186/// extended report rate (`0x8061`) and the keyboard backlight (`0x1982`).
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
188pub(super) struct EventFeatureIndices {
189    pub(super) wireless_status: Option<u8>,
190    pub(super) unified_battery: Option<u8>,
191    pub(super) battery_status: Option<u8>,
192    pub(super) battery_voltage: Option<u8>,
193    pub(super) adc_measurement: Option<u8>,
194    pub(super) fn_inversion: Option<u8>,
195    pub(super) fn_inversion_multi_host: Option<u8>,
196    pub(super) extended_dpi: Option<u8>,
197    pub(super) multi_platform: Option<u8>,
198    pub(super) extended_report_rate: Option<u8>,
199    pub(super) backlight: Option<u8>,
200}
201
202impl EventFeatureIndices {
203    pub(super) fn from_feature_ids(ids: &[u16]) -> Self {
204        let mut indices = Self::default();
205        for (position, id) in ids.iter().copied().enumerate() {
206            let Ok(index) = u8::try_from(position + 1) else {
207                break;
208            };
209            match id {
210                0x1d4b => indices.wireless_status = Some(index),
211                0x1004 => indices.unified_battery = Some(index),
212                0x1000 => indices.battery_status = Some(index),
213                0x1001 => indices.battery_voltage = Some(index),
214                0x1f20 => indices.adc_measurement = Some(index),
215                0x40a2 => indices.fn_inversion = Some(index),
216                0x40a3 => indices.fn_inversion_multi_host = Some(index),
217                0x2202 => indices.extended_dpi = Some(index),
218                0x4531 => indices.multi_platform = Some(index),
219                0x8061 => indices.extended_report_rate = Some(index),
220                0x1982 => indices.backlight = Some(index),
221                _ => {}
222            }
223        }
224        indices
225    }
226
227    fn recognizes(self, message: &v20::Message) -> Option<HidppEventSource> {
228        let header = message.header();
229        if header.software_id.to_lo() != 0 {
230            return None;
231        }
232        let function_id = header.function_id.to_lo();
233        let payload = message.extend_payload();
234        if self.wireless_status == Some(header.feature_index)
235            && WirelessDeviceStatusEvent::decode(function_id, &payload).is_some()
236        {
237            return Some(HidppEventSource::WirelessDeviceStatus);
238        }
239        if self.unified_battery == Some(header.feature_index)
240            && BatteryEvent::decode(function_id, &payload).is_some()
241        {
242            return Some(HidppEventSource::UnifiedBattery);
243        }
244        // Unlike the two above, the rest are not gated on decoding the
245        // payload: the reconciliation re-reads the battery, and an unknown
246        // status or flags value still announces a change.
247        if self.battery_status == Some(header.feature_index)
248            && BatteryStatusFeature::is_status_broadcast(function_id)
249        {
250            return Some(HidppEventSource::BatteryStatus);
251        }
252        if self.battery_voltage == Some(header.feature_index)
253            && BatteryVoltageFeature::is_status_broadcast(function_id)
254        {
255            return Some(HidppEventSource::BatteryVoltage);
256        }
257        if self.adc_measurement == Some(header.feature_index)
258            && AdcMeasurementFeature::is_status_broadcast(function_id)
259        {
260            return Some(HidppEventSource::AdcMeasurement);
261        }
262        // Neither are the settings: a client re-reads the setting itself.
263        if self.fn_inversion == Some(header.feature_index)
264            && FnInversionWithDefaultStateFeature::is_state_broadcast(function_id)
265        {
266            return Some(HidppEventSource::FnInversion);
267        }
268        if self.fn_inversion_multi_host == Some(header.feature_index)
269            && FnInversionMultiHostFeature::is_state_broadcast(message)
270        {
271            return Some(HidppEventSource::FnInversion);
272        }
273        if self.extended_dpi == Some(header.feature_index)
274            && ExtendedDpiFeature::is_parameters_broadcast(function_id)
275        {
276            return Some(HidppEventSource::ExtendedDpi);
277        }
278        // Logi Options+ takes every unsolicited `0x4531` report for a platform
279        // change, whatever its function.
280        if self.multi_platform == Some(header.feature_index)
281            && PlatformChange::decode(&payload).is_some()
282        {
283            return Some(HidppEventSource::MultiPlatform);
284        }
285        if self.extended_report_rate == Some(header.feature_index)
286            && ExtendedReportRateFeature::is_rate_broadcast(function_id)
287        {
288            return Some(HidppEventSource::ExtendedReportRate);
289        }
290        if self.backlight == Some(header.feature_index)
291            && BacklightFeature::is_info_broadcast(function_id)
292        {
293            return Some(HidppEventSource::Backlight);
294        }
295        None
296    }
297}
298
299#[derive(Clone, Copy)]
300struct DeviceEvents {
301    device_index: u8,
302    features: EventFeatureIndices,
303    /// How many setting-change broadcasts this device index has sent on the
304    /// channel. Owned here, never reset by [`EventSubscriptionHandle::register_device`]:
305    /// a re-probe does not undo a change the user made on the device.
306    settings_revision: u64,
307}
308
309struct SubscriptionState {
310    protocol: Option<ReceiverProtocol>,
311    devices: RwLock<Vec<DeviceEvents>>,
312    receiver_snapshot_depth: AtomicUsize,
313    notifier: EventNotifier,
314}
315
316impl SubscriptionState {
317    fn decode(&self, raw: HidppMessage, matched: bool) -> Option<HidppEventSource> {
318        if matched {
319            return None;
320        }
321
322        let receiver_connection = match self.protocol {
323            Some(ReceiverProtocol::Bolt) => matches!(
324                bolt::decode_notification(&v10::Message::from(raw)),
325                Some(bolt::Event::DeviceConnection(_))
326            ),
327            Some(ReceiverProtocol::Unifying) => matches!(
328                unifying::decode_notification(&v10::Message::from(raw)),
329                Some(unifying::Event::DeviceConnection(_))
330            ),
331            None => false,
332        };
333        if receiver_connection {
334            return (self.receiver_snapshot_depth.load(Ordering::Acquire) == 0)
335                .then_some(HidppEventSource::ReceiverConnection);
336        }
337
338        let message = v20::Message::from(raw);
339        let device_index = message.header().device_index;
340        let source = self
341            .devices
342            .read()
343            .unwrap_or_else(PoisonError::into_inner)
344            .iter()
345            .find(|device| device.device_index == device_index)
346            .and_then(|device| device.features.recognizes(&message))?;
347        if source.changes_settings() {
348            // Bumped before the reconciliation request is sent, so the
349            // snapshot that request produces already carries it.
350            if let Some(device) = self
351                .devices
352                .write()
353                .unwrap_or_else(PoisonError::into_inner)
354                .iter_mut()
355                .find(|device| device.device_index == device_index)
356            {
357                device.settings_revision = device.settings_revision.wrapping_add(1);
358            }
359        }
360        Some(source)
361    }
362}
363
364/// Cloneable registration handle passed through a node's probe.
365#[derive(Clone)]
366pub(super) struct EventSubscriptionHandle {
367    state: Arc<SubscriptionState>,
368}
369
370impl EventSubscriptionHandle {
371    /// Replace the decoder metadata for `device_index` as soon as a valid
372    /// feature table (or its cached equivalent) is available. The channel
373    /// listener was installed before the probe began, closing the wakeup race
374    /// before the resulting snapshot is published.
375    pub(super) fn register_device(&self, device_index: u8, features: EventFeatureIndices) {
376        let mut devices = self
377            .state
378            .devices
379            .write()
380            .unwrap_or_else(PoisonError::into_inner);
381        if let Some(device) = devices
382            .iter_mut()
383            .find(|device| device.device_index == device_index)
384        {
385            device.features = features;
386        } else {
387            devices.push(DeviceEvents {
388                device_index,
389                features,
390                settings_revision: 0,
391            });
392        }
393    }
394
395    /// How many setting-change broadcasts `device_index` has sent on this
396    /// channel; `0` before its first, or when it was never registered.
397    pub(super) fn settings_revision(&self, device_index: u8) -> u64 {
398        self.state
399            .devices
400            .read()
401            .unwrap_or_else(PoisonError::into_inner)
402            .iter()
403            .find(|device| device.device_index == device_index)
404            .map_or(0, |device| device.settings_revision)
405    }
406
407    /// Suppress receiver connection events fabricated by the enumerator's own
408    /// arrival trigger. A real event in this interval is covered by the probe
409    /// already in progress; events immediately before or after remain queued.
410    pub(super) fn begin_receiver_snapshot(&self) -> ReceiverSnapshotGuard {
411        self.state
412            .receiver_snapshot_depth
413            .fetch_add(1, Ordering::AcqRel);
414        ReceiverSnapshotGuard {
415            state: Arc::clone(&self.state),
416        }
417    }
418}
419
420/// Persistent listener owned alongside one cached HID++ channel.
421pub(super) struct ChannelEventSubscriptions {
422    handle: EventSubscriptionHandle,
423    _listener: MessageListenerGuard,
424}
425
426impl ChannelEventSubscriptions {
427    pub(super) fn attach(
428        channel: &Arc<HidppChannel>,
429        protocol: Option<ReceiverProtocol>,
430        notifier: EventNotifier,
431    ) -> Self {
432        let state = Arc::new(SubscriptionState {
433            protocol,
434            devices: RwLock::new(Vec::new()),
435            receiver_snapshot_depth: AtomicUsize::new(0),
436            notifier,
437        });
438        let listener = channel.add_msg_listener_guarded({
439            let state = Arc::clone(&state);
440            move |raw, matched| {
441                if let Some(source) = state.decode(raw, matched) {
442                    state.notifier.notify(source);
443                }
444            }
445        });
446        Self {
447            handle: EventSubscriptionHandle { state },
448            _listener: listener,
449        }
450    }
451
452    pub(super) fn handle(&self) -> EventSubscriptionHandle {
453        self.handle.clone()
454    }
455}
456
457/// Receiver-arrival suppression scoped to one authoritative snapshot.
458pub(super) struct ReceiverSnapshotGuard {
459    state: Arc<SubscriptionState>,
460}
461
462impl Drop for ReceiverSnapshotGuard {
463    fn drop(&mut self) {
464        self.state
465            .receiver_snapshot_depth
466            .fetch_sub(1, Ordering::AcqRel);
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use hidpp::nibble::U4;
473    use hidpp::protocol::{v10, v20};
474
475    use super::*;
476
477    fn state(protocol: Option<ReceiverProtocol>) -> Arc<SubscriptionState> {
478        let (notifier, _receiver) = event_channel();
479        Arc::new(SubscriptionState {
480            protocol,
481            devices: RwLock::new(Vec::new()),
482            receiver_snapshot_depth: AtomicUsize::new(0),
483            notifier,
484        })
485    }
486
487    #[test]
488    fn feature_indices_are_runtime_table_positions() {
489        assert_eq!(
490            EventFeatureIndices::from_feature_ids(&[0x0001, 0x1d4b, 0x1004]),
491            EventFeatureIndices {
492                wireless_status: Some(2),
493                unified_battery: Some(3),
494                battery_status: None,
495                battery_voltage: None,
496                adc_measurement: None,
497                fn_inversion: None,
498                fn_inversion_multi_host: None,
499                extended_dpi: None,
500                multi_platform: None,
501                extended_report_rate: None,
502                backlight: None,
503            }
504        );
505    }
506
507    #[test]
508    fn receiver_snapshot_suppresses_only_triggered_connection_wakeups() {
509        let state = state(Some(ReceiverProtocol::Unifying));
510        let raw = v10::Message::Short(
511            v10::MessageHeader {
512                device_index: 2,
513                sub_id: 0x41,
514            },
515            [0, 0x02, 0x34, 0x12],
516        )
517        .into();
518
519        assert_eq!(
520            state.decode(raw, false),
521            Some(HidppEventSource::ReceiverConnection)
522        );
523        state.receiver_snapshot_depth.store(1, Ordering::Release);
524        assert_eq!(state.decode(raw, false), None);
525    }
526
527    #[test]
528    fn receiver_snapshot_does_not_suppress_device_feature_events() {
529        let state = state(Some(ReceiverProtocol::Bolt));
530        state.devices.write().unwrap().push(DeviceEvents {
531            device_index: 2,
532            settings_revision: 0,
533            features: EventFeatureIndices {
534                wireless_status: Some(5),
535                unified_battery: None,
536                battery_status: None,
537                battery_voltage: None,
538                adc_measurement: None,
539                fn_inversion: None,
540                fn_inversion_multi_host: None,
541                extended_dpi: None,
542                multi_platform: None,
543                extended_report_rate: None,
544                backlight: None,
545            },
546        });
547        state.receiver_snapshot_depth.store(1, Ordering::Release);
548        let wireless = v20::Message::Long(
549            v20::MessageHeader {
550                device_index: 2,
551                feature_index: 5,
552                function_id: U4::from_lo(0),
553                software_id: U4::from_lo(0),
554            },
555            [0; 16],
556        )
557        .into();
558
559        assert_eq!(
560            state.decode(wireless, false),
561            Some(HidppEventSource::WirelessDeviceStatus)
562        );
563    }
564
565    #[test]
566    fn registered_device_events_decode_to_typed_sources() {
567        let state = state(None);
568        state.devices.write().unwrap().push(DeviceEvents {
569            device_index: 3,
570            settings_revision: 0,
571            features: EventFeatureIndices {
572                wireless_status: Some(5),
573                unified_battery: Some(7),
574                battery_status: None,
575                battery_voltage: None,
576                adc_measurement: None,
577                fn_inversion: None,
578                fn_inversion_multi_host: None,
579                extended_dpi: None,
580                multi_platform: None,
581                extended_report_rate: None,
582                backlight: None,
583            },
584        });
585
586        let wireless = v20::Message::Long(
587            v20::MessageHeader {
588                device_index: 3,
589                feature_index: 5,
590                function_id: U4::from_lo(0),
591                software_id: U4::from_lo(0),
592            },
593            [0; 16],
594        )
595        .into();
596        assert_eq!(
597            state.decode(wireless, false),
598            Some(HidppEventSource::WirelessDeviceStatus)
599        );
600
601        let mut payload = [0; 16];
602        payload[0] = 80;
603        payload[1] = 4;
604        let battery = v20::Message::Long(
605            v20::MessageHeader {
606                device_index: 3,
607                feature_index: 7,
608                function_id: U4::from_lo(0),
609                software_id: U4::from_lo(0),
610            },
611            payload,
612        )
613        .into();
614        assert_eq!(
615            state.decode(battery, false),
616            Some(HidppEventSource::UnifiedBattery)
617        );
618    }
619
620    #[test]
621    fn adc_measurement_index_is_its_table_position() {
622        // The G733 feature table, 0x1F20 at runtime index 8.
623        let table = [
624            0x0001, 0x0003, 0x0005, 0x8070, 0x8010, 0x8310, 0x8300, 0x1f20,
625        ];
626        assert_eq!(
627            EventFeatureIndices::from_feature_ids(&table).adc_measurement,
628            Some(8)
629        );
630    }
631
632    #[test]
633    fn adc_measurement_broadcasts_request_reconciliation() {
634        let state = state(None);
635        state.devices.write().unwrap().push(DeviceEvents {
636            device_index: 0xff,
637            settings_revision: 0,
638            features: EventFeatureIndices {
639                wireless_status: None,
640                unified_battery: None,
641                battery_status: None,
642                battery_voltage: None,
643                adc_measurement: Some(8),
644                fn_inversion: None,
645                fn_inversion_multi_host: None,
646                extended_dpi: None,
647                multi_platform: None,
648                extended_report_rate: None,
649                backlight: None,
650            },
651        });
652        let event = |function_id: u8, software_id: u8, bytes: [u8; 3]| {
653            let mut payload = [0; 16];
654            payload[..3].copy_from_slice(&bytes);
655            v20::Message::Long(
656                v20::MessageHeader {
657                    device_index: 0xff,
658                    feature_index: 8,
659                    function_id: U4::from_lo(function_id),
660                    software_id: U4::from_lo(software_id),
661                },
662                payload,
663            )
664            .into()
665        };
666
667        // Both broadcasts captured from a G733 dongle: `11 ff 08 00 00 00 00`
668        // as the headset switched off, `11 ff 08 00 10 4a 03` as it switched
669        // back on while charging.
670        for bytes in [[0x00, 0x00, 0x00], [0x10, 0x4a, 0x03]] {
671            assert_eq!(
672                state.decode(event(0, 0, bytes), false),
673                Some(HidppEventSource::AdcMeasurement),
674                "{bytes:02x?}"
675            );
676        }
677        // A broadcast whose flags value is unknown (the kernel's `0x0F`) still
678        // announces a change; the reconciliation's read decides what it means.
679        assert_eq!(
680            state.decode(event(0, 0, [0x10, 0x4a, 0x0f]), false),
681            Some(HidppEventSource::AdcMeasurement)
682        );
683        // A reply to our own request carries a software id; not an event.
684        assert_eq!(state.decode(event(0, 0xd, [0x10, 0x4a, 0x01]), false), None);
685        // Only event `0` is the status broadcast.
686        assert_eq!(state.decode(event(1, 0, [0x10, 0x4a, 0x01]), false), None);
687    }
688
689    #[test]
690    fn legacy_and_voltage_battery_indexes_are_table_positions() {
691        // An MX Master 2S-shaped table: 0x1000 instead of 0x1004.
692        let legacy =
693            EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x0005, 0x1000, 0x1d4b]);
694        assert_eq!(legacy.battery_status, Some(4));
695        assert_eq!(legacy.battery_voltage, None);
696        assert_eq!(legacy.unified_battery, None);
697        // A G-series-shaped table: 0x1001 and no other battery source.
698        let voltage = EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x1001, 0x8070]);
699        assert_eq!(voltage.battery_voltage, Some(3));
700        assert_eq!(voltage.battery_status, None);
701    }
702
703    #[test]
704    fn legacy_and_voltage_battery_broadcasts_request_reconciliation() {
705        let state = state(None);
706        state.devices.write().unwrap().push(DeviceEvents {
707            device_index: 1,
708            settings_revision: 0,
709            features: EventFeatureIndices {
710                battery_status: Some(4),
711                battery_voltage: Some(6),
712                ..EventFeatureIndices::default()
713            },
714        });
715        let event = |feature_index: u8, function_id: u8, software_id: u8, bytes: [u8; 3]| {
716            let mut payload = [0; 16];
717            payload[..3].copy_from_slice(&bytes);
718            v20::Message::Long(
719                v20::MessageHeader {
720                    device_index: 1,
721                    feature_index,
722                    function_id: U4::from_lo(function_id),
723                    software_id: U4::from_lo(software_id),
724                },
725                payload,
726            )
727            .into()
728        };
729
730        // 0x1000: level 50 %, next 20 %, discharging; then a status value
731        // (`0x09`) the crate does not know, which still announces a change.
732        for bytes in [[50, 20, 0x00], [0, 0, 0x09]] {
733            assert_eq!(
734                state.decode(event(4, 0, 0, bytes), false),
735                Some(HidppEventSource::BatteryStatus),
736                "{bytes:02x?}"
737            );
738        }
739        // 0x1001: 3.9 V while charging (flags bit 7).
740        assert_eq!(
741            state.decode(event(6, 0, 0, [0x0f, 0x3c, 0x80]), false),
742            Some(HidppEventSource::BatteryVoltage)
743        );
744        for (feature_index, source) in [(4, "0x1000"), (6, "0x1001")] {
745            // The reply to our own read is matched to its request, and a reply
746            // carries a software id: neither is a broadcast.
747            assert_eq!(
748                state.decode(event(feature_index, 0, 0, [50, 20, 0]), true),
749                None,
750                "{source}"
751            );
752            assert_eq!(
753                state.decode(event(feature_index, 0, 0xd, [50, 20, 0]), false),
754                None,
755                "{source}"
756            );
757            // Only event `0` is the status broadcast.
758            assert_eq!(
759                state.decode(event(feature_index, 1, 0, [50, 20, 0]), false),
760                None,
761                "{source}"
762            );
763        }
764        // Another feature's index on the same device is not a battery event.
765        assert_eq!(state.decode(event(5, 0, 0, [50, 20, 0]), false), None);
766    }
767
768    #[test]
769    fn setting_feature_indexes_are_table_positions() {
770        // An MX Keys-shaped table with 0x40a3, and a G502-shaped one with
771        // 0x2202; 0x40a2 is the single-host keyboards' predecessor.
772        let keyboard = EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x1d4b, 0x40a3]);
773        assert_eq!(keyboard.fn_inversion_multi_host, Some(4));
774        assert_eq!(keyboard.fn_inversion, None);
775        let single_host = EventFeatureIndices::from_feature_ids(&[0x0001, 0x40a2]);
776        assert_eq!(single_host.fn_inversion, Some(2));
777        let mouse = EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x2202, 0x1001]);
778        assert_eq!(mouse.extended_dpi, Some(3));
779        assert_eq!(mouse.battery_voltage, Some(4));
780    }
781
782    #[test]
783    fn setting_broadcasts_request_reconciliation_and_bump_the_revision() {
784        let state = state(Some(ReceiverProtocol::Bolt));
785        let handle = EventSubscriptionHandle {
786            state: Arc::clone(&state),
787        };
788        let features = EventFeatureIndices {
789            fn_inversion: Some(4),
790            fn_inversion_multi_host: Some(5),
791            extended_dpi: Some(6),
792            battery_status: Some(7),
793            ..EventFeatureIndices::default()
794        };
795        handle.register_device(1, features);
796        handle.register_device(2, features);
797        let long = |device_index: u8, feature_index: u8, function_id: u8, software_id: u8| {
798            v20::Message::Long(
799                v20::MessageHeader {
800                    device_index,
801                    feature_index,
802                    function_id: U4::from_lo(function_id),
803                    software_id: U4::from_lo(software_id),
804                },
805                [0; 16],
806            )
807            .into()
808        };
809        assert_eq!(handle.settings_revision(1), 0);
810
811        // 0x40a2: function 1 only.
812        assert_eq!(
813            state.decode(long(1, 4, 1, 0), false),
814            Some(HidppEventSource::FnInversion)
815        );
816        assert_eq!(handle.settings_revision(1), 1);
817        assert_eq!(state.decode(long(1, 4, 0, 0), false), None);
818        // 0x40a3: any function, but only a long report.
819        assert_eq!(
820            state.decode(long(1, 5, 0, 0), false),
821            Some(HidppEventSource::FnInversion)
822        );
823        let short = v20::Message::Short(
824            v20::MessageHeader {
825                device_index: 1,
826                feature_index: 5,
827                function_id: U4::from_lo(0),
828                software_id: U4::from_lo(0),
829            },
830            [1, 1, 0],
831        )
832        .into();
833        assert_eq!(state.decode(short, false), None);
834        assert_eq!(handle.settings_revision(1), 2);
835        // 0x2202: the parameters broadcast, not a calibration result.
836        assert_eq!(
837            state.decode(long(1, 6, 0, 0), false),
838            Some(HidppEventSource::ExtendedDpi)
839        );
840        assert_eq!(state.decode(long(1, 6, 1, 0), false), None);
841        assert_eq!(handle.settings_revision(1), 3);
842
843        // Our own traffic is not a device-side change: a matched reply, or a
844        // message carrying a software id.
845        assert_eq!(state.decode(long(1, 5, 1, 0), true), None);
846        assert_eq!(state.decode(long(1, 5, 1, 0xd), false), None);
847        // A battery broadcast requests reconciliation but changes no setting.
848        assert_eq!(
849            state.decode(long(1, 7, 0, 0), false),
850            Some(HidppEventSource::BatteryStatus)
851        );
852        assert_eq!(handle.settings_revision(1), 3);
853
854        // A re-probe re-registers the device; the change it already made
855        // stands.
856        handle.register_device(1, features);
857        assert_eq!(handle.settings_revision(1), 3);
858        // The revision is per device index, and an unknown index has none.
859        assert_eq!(handle.settings_revision(2), 0);
860        assert_eq!(state.decode(long(3, 5, 0, 0), false), None);
861        assert_eq!(handle.settings_revision(3), 0);
862    }
863
864    #[test]
865    fn platform_change_reports_bump_the_revision() {
866        let keyboard = EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x40a3, 0x4531]);
867        assert_eq!(keyboard.multi_platform, Some(4));
868
869        let state = state(Some(ReceiverProtocol::Bolt));
870        let handle = EventSubscriptionHandle {
871            state: Arc::clone(&state),
872        };
873        handle.register_device(1, keyboard);
874        let report = |function_id: u8, software_id: u8| {
875            v20::Message::Short(
876                v20::MessageHeader {
877                    device_index: 1,
878                    feature_index: 4,
879                    function_id: U4::from_lo(function_id),
880                    software_id: U4::from_lo(software_id),
881                },
882                [1, 2, 2],
883            )
884            .into()
885        };
886
887        // Options+ takes any unsolicited 0x4531 report for a platform change.
888        for (function_id, revision) in [(0, 1), (1, 2)] {
889            assert_eq!(
890                state.decode(report(function_id, 0), false),
891                Some(HidppEventSource::MultiPlatform)
892            );
893            assert_eq!(handle.settings_revision(1), revision);
894        }
895        // A reply to our own request is not.
896        assert_eq!(state.decode(report(2, 0), true), None);
897        assert_eq!(state.decode(report(2, 0xd), false), None);
898        assert_eq!(handle.settings_revision(1), 2);
899    }
900
901    #[test]
902    fn backlight_info_broadcasts_bump_the_revision() {
903        // An MX Keys S-shaped table: Fn lock, backlight, multi-platform.
904        let keyboard =
905            EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x40a3, 0x1982, 0x4531]);
906        assert_eq!(keyboard.backlight, Some(4));
907
908        let state = state(Some(ReceiverProtocol::Bolt));
909        let handle = EventSubscriptionHandle {
910            state: Arc::clone(&state),
911        };
912        handle.register_device(1, keyboard);
913        let report = |function_id: u8, software_id: u8| {
914            v20::Message::Long(
915                v20::MessageHeader {
916                    device_index: 1,
917                    feature_index: 4,
918                    function_id: U4::from_lo(function_id),
919                    software_id: U4::from_lo(software_id),
920                },
921                [8, 4, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
922            )
923            .into()
924        };
925
926        // Options+ takes exactly event 0, `backlightInfoEvent`.
927        assert_eq!(
928            state.decode(report(0, 0), false),
929            Some(HidppEventSource::Backlight)
930        );
931        assert_eq!(handle.settings_revision(1), 1);
932        assert_eq!(state.decode(report(1, 0), false), None);
933        // A reply to our own `getBacklightConfig` is not one.
934        assert_eq!(state.decode(report(0, 0), true), None);
935        assert_eq!(state.decode(report(0, 0xd), false), None);
936        assert_eq!(handle.settings_revision(1), 1);
937    }
938
939    #[test]
940    fn report_rate_broadcasts_bump_the_revision() {
941        // A G502 X-shaped table: 0x8061 and, as the newer mice keep it, 0x8060.
942        let mouse =
943            EventFeatureIndices::from_feature_ids(&[0x0001, 0x0003, 0x2202, 0x8060, 0x8061]);
944        assert_eq!(mouse.extended_report_rate, Some(5));
945
946        let state = state(Some(ReceiverProtocol::Bolt));
947        let handle = EventSubscriptionHandle {
948            state: Arc::clone(&state),
949        };
950        handle.register_device(1, mouse);
951        let report = |feature_index: u8, function_id: u8, software_id: u8| {
952            v20::Message::Short(
953                v20::MessageHeader {
954                    device_index: 1,
955                    feature_index,
956                    function_id: U4::from_lo(function_id),
957                    software_id: U4::from_lo(software_id),
958                },
959                [3, 0, 0],
960            )
961            .into()
962        };
963
964        // Options+ takes exactly function 0 for the rate broadcast.
965        assert_eq!(
966            state.decode(report(5, 0, 0), false),
967            Some(HidppEventSource::ExtendedReportRate)
968        );
969        assert_eq!(handle.settings_revision(1), 1);
970        assert_eq!(state.decode(report(5, 2, 0), false), None);
971        // A reply to our own request is not one.
972        assert_eq!(state.decode(report(5, 0, 0), true), None);
973        assert_eq!(state.decode(report(5, 0, 0xd), false), None);
974        // 0x8060 has no broadcast.
975        assert_eq!(state.decode(report(4, 0, 0), false), None);
976        assert_eq!(handle.settings_revision(1), 1);
977    }
978
979    #[test]
980    fn event_requests_are_bounded_and_coalesced() {
981        let (notifier, mut receiver) = event_channel();
982        notifier.notify(HidppEventSource::ReceiverConnection);
983        notifier.notify(HidppEventSource::UnifiedBattery);
984
985        assert_eq!(
986            receiver.try_recv(),
987            Ok(HidppEventSource::ReceiverConnection)
988        );
989        assert_eq!(receiver.try_recv(), Err(mpsc::error::TryRecvError::Empty));
990    }
991}