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
9use std::sync::atomic::{AtomicUsize, Ordering};
10use std::sync::{Arc, PoisonError, RwLock};
11
12use hidpp::channel::{HidppChannel, HidppMessage, MessageListenerGuard};
13use hidpp::feature::unified_battery::BatteryEvent;
14use hidpp::feature::wireless_device_status::WirelessDeviceStatusEvent;
15use hidpp::protocol::{v10, v20};
16use hidpp::receiver::{bolt, unifying};
17use serde::{Deserialize, Serialize};
18#[cfg(test)]
19use tokio::sync::Notify;
20use tokio::sync::mpsc;
21
22use crate::ReceiverProtocol;
23
24/// A HID++ lifecycle source that requested authoritative inventory
25/// reconciliation.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum HidppEventSource {
28    /// A receiver reported a paired slot connecting or disconnecting.
29    ReceiverConnection,
30    /// A device's `WirelessDeviceStatus` feature reported reconnection.
31    WirelessDeviceStatus,
32    /// A device's event-capable `UnifiedBattery` feature reported a change.
33    UnifiedBattery,
34}
35
36/// The sending half of the bounded HID++ reconciliation-request channel.
37///
38/// Clones are installed only on inventory-owned channels. Capacity is one:
39/// every source asks for the same full reconciliation, so a burst has no
40/// additional meaning and must not grow memory while a slow probe is running.
41#[derive(Clone)]
42pub struct EventNotifier {
43    sender: mpsc::Sender<HidppEventSource>,
44    #[cfg(test)]
45    observation: Option<Arc<EventObservation>>,
46}
47
48impl EventNotifier {
49    fn notify(&self, source: HidppEventSource) {
50        let _ = self.sender.try_send(source);
51        #[cfg(test)]
52        if let Some(observation) = &self.observation {
53            observation.count.fetch_add(1, Ordering::Release);
54            observation.changed.notify_waiters();
55        }
56    }
57}
58
59/// The receiving half of the coalesced HID++ reconciliation-request channel.
60pub type EventReceiver = mpsc::Receiver<HidppEventSource>;
61
62/// Build the bounded channel used by an inventory watcher and its enumerator.
63#[must_use]
64pub fn event_channel() -> (EventNotifier, EventReceiver) {
65    let (sender, receiver) = mpsc::channel(1);
66    (
67        EventNotifier {
68            sender,
69            #[cfg(test)]
70            observation: None,
71        },
72        receiver,
73    )
74}
75
76#[cfg(test)]
77struct EventObservation {
78    count: AtomicUsize,
79    changed: Notify,
80}
81
82/// Barrier observing decoded lifecycle events, including coalesced requests.
83#[cfg(test)]
84pub(crate) struct EventObserver {
85    observation: Arc<EventObservation>,
86}
87
88#[cfg(test)]
89impl EventObserver {
90    pub(crate) async fn wait_for(&self, expected: usize) {
91        loop {
92            let changed = self.observation.changed.notified();
93            if self.observation.count.load(Ordering::Acquire) >= expected {
94                return;
95            }
96            changed.await;
97        }
98    }
99}
100
101/// Build the production capacity-one event channel plus a decode observation barrier.
102#[cfg(test)]
103pub(crate) fn observed_event_channel() -> (EventNotifier, EventReceiver, EventObserver) {
104    let (sender, receiver) = mpsc::channel(1);
105    let observation = Arc::new(EventObservation {
106        count: AtomicUsize::new(0),
107        changed: Notify::new(),
108    });
109    (
110        EventNotifier {
111            sender,
112            observation: Some(Arc::clone(&observation)),
113        },
114        receiver,
115        EventObserver { observation },
116    )
117}
118
119/// Runtime feature indexes whose unsolicited events affect inventory.
120///
121/// Stored with the immutable feature-table cache because these indexes are
122/// discovered by the same walk. Only unified battery has a battery event;
123/// legacy `0x1000` and voltage `0x1001` remain recovery-scan reads.
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub(super) struct EventFeatureIndices {
126    pub(super) wireless_status: Option<u8>,
127    pub(super) unified_battery: Option<u8>,
128}
129
130impl EventFeatureIndices {
131    pub(super) fn from_feature_ids(ids: &[u16]) -> Self {
132        let mut indices = Self::default();
133        for (position, id) in ids.iter().copied().enumerate() {
134            let Ok(index) = u8::try_from(position + 1) else {
135                break;
136            };
137            match id {
138                0x1d4b => indices.wireless_status = Some(index),
139                0x1004 => indices.unified_battery = Some(index),
140                _ => {}
141            }
142        }
143        indices
144    }
145
146    fn recognizes(self, message: &v20::Message) -> Option<HidppEventSource> {
147        let header = message.header();
148        if header.software_id.to_lo() != 0 {
149            return None;
150        }
151        let function_id = header.function_id.to_lo();
152        let payload = message.extend_payload();
153        if self.wireless_status == Some(header.feature_index)
154            && WirelessDeviceStatusEvent::decode(function_id, &payload).is_some()
155        {
156            return Some(HidppEventSource::WirelessDeviceStatus);
157        }
158        if self.unified_battery == Some(header.feature_index)
159            && BatteryEvent::decode(function_id, &payload).is_some()
160        {
161            return Some(HidppEventSource::UnifiedBattery);
162        }
163        None
164    }
165}
166
167#[derive(Clone, Copy)]
168struct DeviceEvents {
169    device_index: u8,
170    features: EventFeatureIndices,
171}
172
173struct SubscriptionState {
174    protocol: Option<ReceiverProtocol>,
175    devices: RwLock<Vec<DeviceEvents>>,
176    receiver_snapshot_depth: AtomicUsize,
177    notifier: EventNotifier,
178}
179
180impl SubscriptionState {
181    fn decode(&self, raw: HidppMessage, matched: bool) -> Option<HidppEventSource> {
182        if matched {
183            return None;
184        }
185
186        let receiver_connection = match self.protocol {
187            Some(ReceiverProtocol::Bolt) => matches!(
188                bolt::decode_notification(&v10::Message::from(raw)),
189                Some(bolt::Event::DeviceConnection(_))
190            ),
191            Some(ReceiverProtocol::Unifying) => matches!(
192                unifying::decode_notification(&v10::Message::from(raw)),
193                Some(unifying::Event::DeviceConnection(_))
194            ),
195            None => false,
196        };
197        if receiver_connection {
198            return (self.receiver_snapshot_depth.load(Ordering::Acquire) == 0)
199                .then_some(HidppEventSource::ReceiverConnection);
200        }
201
202        let message = v20::Message::from(raw);
203        let device_index = message.header().device_index;
204        self.devices
205            .read()
206            .unwrap_or_else(PoisonError::into_inner)
207            .iter()
208            .find(|device| device.device_index == device_index)
209            .and_then(|device| device.features.recognizes(&message))
210    }
211}
212
213/// Cloneable registration handle passed through a node's probe.
214#[derive(Clone)]
215pub(super) struct EventSubscriptionHandle {
216    state: Arc<SubscriptionState>,
217}
218
219impl EventSubscriptionHandle {
220    /// Replace the decoder metadata for `device_index` as soon as a valid
221    /// feature table (or its cached equivalent) is available. The channel
222    /// listener was installed before the probe began, closing the wakeup race
223    /// before the resulting snapshot is published.
224    pub(super) fn register_device(&self, device_index: u8, features: EventFeatureIndices) {
225        let mut devices = self
226            .state
227            .devices
228            .write()
229            .unwrap_or_else(PoisonError::into_inner);
230        if let Some(device) = devices
231            .iter_mut()
232            .find(|device| device.device_index == device_index)
233        {
234            device.features = features;
235        } else {
236            devices.push(DeviceEvents {
237                device_index,
238                features,
239            });
240        }
241    }
242
243    /// Suppress receiver connection events fabricated by the enumerator's own
244    /// arrival trigger. A real event in this interval is covered by the probe
245    /// already in progress; events immediately before or after remain queued.
246    pub(super) fn begin_receiver_snapshot(&self) -> ReceiverSnapshotGuard {
247        self.state
248            .receiver_snapshot_depth
249            .fetch_add(1, Ordering::AcqRel);
250        ReceiverSnapshotGuard {
251            state: Arc::clone(&self.state),
252        }
253    }
254}
255
256/// Persistent listener owned alongside one cached HID++ channel.
257pub(super) struct ChannelEventSubscriptions {
258    handle: EventSubscriptionHandle,
259    _listener: MessageListenerGuard,
260}
261
262impl ChannelEventSubscriptions {
263    pub(super) fn attach(
264        channel: &Arc<HidppChannel>,
265        protocol: Option<ReceiverProtocol>,
266        notifier: EventNotifier,
267    ) -> Self {
268        let state = Arc::new(SubscriptionState {
269            protocol,
270            devices: RwLock::new(Vec::new()),
271            receiver_snapshot_depth: AtomicUsize::new(0),
272            notifier,
273        });
274        let listener = channel.add_msg_listener_guarded({
275            let state = Arc::clone(&state);
276            move |raw, matched| {
277                if let Some(source) = state.decode(raw, matched) {
278                    state.notifier.notify(source);
279                }
280            }
281        });
282        Self {
283            handle: EventSubscriptionHandle { state },
284            _listener: listener,
285        }
286    }
287
288    pub(super) fn handle(&self) -> EventSubscriptionHandle {
289        self.handle.clone()
290    }
291}
292
293/// Receiver-arrival suppression scoped to one authoritative snapshot.
294pub(super) struct ReceiverSnapshotGuard {
295    state: Arc<SubscriptionState>,
296}
297
298impl Drop for ReceiverSnapshotGuard {
299    fn drop(&mut self) {
300        self.state
301            .receiver_snapshot_depth
302            .fetch_sub(1, Ordering::AcqRel);
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use hidpp::nibble::U4;
309    use hidpp::protocol::{v10, v20};
310
311    use super::*;
312
313    fn state(protocol: Option<ReceiverProtocol>) -> Arc<SubscriptionState> {
314        let (notifier, _receiver) = event_channel();
315        Arc::new(SubscriptionState {
316            protocol,
317            devices: RwLock::new(Vec::new()),
318            receiver_snapshot_depth: AtomicUsize::new(0),
319            notifier,
320        })
321    }
322
323    #[test]
324    fn feature_indices_are_runtime_table_positions() {
325        assert_eq!(
326            EventFeatureIndices::from_feature_ids(&[0x0001, 0x1d4b, 0x1004]),
327            EventFeatureIndices {
328                wireless_status: Some(2),
329                unified_battery: Some(3),
330            }
331        );
332    }
333
334    #[test]
335    fn receiver_snapshot_suppresses_only_triggered_connection_wakeups() {
336        let state = state(Some(ReceiverProtocol::Unifying));
337        let raw = v10::Message::Short(
338            v10::MessageHeader {
339                device_index: 2,
340                sub_id: 0x41,
341            },
342            [0, 0x02, 0x34, 0x12],
343        )
344        .into();
345
346        assert_eq!(
347            state.decode(raw, false),
348            Some(HidppEventSource::ReceiverConnection)
349        );
350        state.receiver_snapshot_depth.store(1, Ordering::Release);
351        assert_eq!(state.decode(raw, false), None);
352    }
353
354    #[test]
355    fn receiver_snapshot_does_not_suppress_device_feature_events() {
356        let state = state(Some(ReceiverProtocol::Bolt));
357        state.devices.write().unwrap().push(DeviceEvents {
358            device_index: 2,
359            features: EventFeatureIndices {
360                wireless_status: Some(5),
361                unified_battery: None,
362            },
363        });
364        state.receiver_snapshot_depth.store(1, Ordering::Release);
365        let wireless = v20::Message::Long(
366            v20::MessageHeader {
367                device_index: 2,
368                feature_index: 5,
369                function_id: U4::from_lo(0),
370                software_id: U4::from_lo(0),
371            },
372            [0; 16],
373        )
374        .into();
375
376        assert_eq!(
377            state.decode(wireless, false),
378            Some(HidppEventSource::WirelessDeviceStatus)
379        );
380    }
381
382    #[test]
383    fn registered_device_events_decode_to_typed_sources() {
384        let state = state(None);
385        state.devices.write().unwrap().push(DeviceEvents {
386            device_index: 3,
387            features: EventFeatureIndices {
388                wireless_status: Some(5),
389                unified_battery: Some(7),
390            },
391        });
392
393        let wireless = v20::Message::Long(
394            v20::MessageHeader {
395                device_index: 3,
396                feature_index: 5,
397                function_id: U4::from_lo(0),
398                software_id: U4::from_lo(0),
399            },
400            [0; 16],
401        )
402        .into();
403        assert_eq!(
404            state.decode(wireless, false),
405            Some(HidppEventSource::WirelessDeviceStatus)
406        );
407
408        let mut payload = [0; 16];
409        payload[0] = 80;
410        payload[1] = 4;
411        let battery = v20::Message::Long(
412            v20::MessageHeader {
413                device_index: 3,
414                feature_index: 7,
415                function_id: U4::from_lo(0),
416                software_id: U4::from_lo(0),
417            },
418            payload,
419        )
420        .into();
421        assert_eq!(
422            state.decode(battery, false),
423            Some(HidppEventSource::UnifiedBattery)
424        );
425    }
426
427    #[test]
428    fn event_requests_are_bounded_and_coalesced() {
429        let (notifier, mut receiver) = event_channel();
430        notifier.notify(HidppEventSource::ReceiverConnection);
431        notifier.notify(HidppEventSource::UnifiedBattery);
432
433        assert_eq!(
434            receiver.try_recv(),
435            Ok(HidppEventSource::ReceiverConnection)
436        );
437        assert_eq!(receiver.try_recv(), Err(mpsc::error::TryRecvError::Empty));
438    }
439}