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