Skip to main content

openlogi_device/write/
haptic.rs

1use std::sync::Arc;
2
3use hidpp::{
4    channel::HidppChannel,
5    device::Device,
6    feature::{
7        CreatableFeature as _,
8        haptic_feedback::{HapticFeedbackFeature, HapticIntensity, HapticWaveform},
9    },
10};
11
12use crate::backend::HidBackend;
13use crate::channel::route::DeviceRoute;
14use crate::{ChannelRegistry, SharedChannel};
15
16use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
17
18async fn feature_on_channel(
19    channel: &Arc<HidppChannel>,
20    device_index: u8,
21) -> Result<Arc<HapticFeedbackFeature>, WriteError> {
22    let mut device = Device::new(Arc::clone(channel), device_index)
23        .await
24        .map_err(|_| WriteError::DeviceUnreachable {
25            index: device_index,
26        })?;
27    open_feature::<HapticFeedbackFeature>(&mut device).await
28}
29
30/// Last successfully-opened haptic feature, keyed by channel identity and
31/// device index. Haptic plays are fired per ring hover, and the open sequence
32/// (device ping + feature lookup) costs two extra HID++ round-trips per play —
33/// on a busy receiver each round-trip is a fresh chance to lose the reply
34/// under concurrent pointer traffic. One entry suffices: haptics come from
35/// one pointing device at a time.
36///
37/// Stores are guarded twice, because a retire can land on either side of an
38/// open and both leave the same wreckage: an entry pinning a channel's `Arc`
39/// after the retire-time clear ran, which recreates the exact reopen deadlock
40/// that clear exists to break.
41///
42/// - A retire *during* the open is caught by the epoch: every clear bumps it,
43///   and a store whose snapshot predates the clear is discarded.
44/// - A retire *before* the open is invisible to the epoch — the snapshot is
45///   already post-clear — so the store additionally asks the registry whether
46///   it still publishes the channel.
47struct EpochGuarded<T> {
48    epoch: u64,
49    entry: Option<(usize, u8, T)>,
50}
51
52impl<T: Clone> EpochGuarded<T> {
53    const fn new() -> Self {
54        Self {
55            epoch: 0,
56            entry: None,
57        }
58    }
59
60    fn get(&self, ptr: usize, index: u8) -> Option<T> {
61        let (entry_ptr, entry_index, value) = self.entry.as_ref()?;
62        (*entry_ptr == ptr && *entry_index == index).then(|| value.clone())
63    }
64
65    /// Store `value`, unless a clear ran since `epoch` was snapshotted or
66    /// `still_current` reports the channel is no longer published.
67    ///
68    /// The epoch alone only sees a clear that lands *during* the open. A
69    /// channel retired *before* it began leaves nothing to violate: the
70    /// snapshot is already post-clear, so the store would land and re-pin a
71    /// dead channel. `still_current` is what closes that half, and it is
72    /// evaluated here — under the caller's lock — on purpose: checked earlier,
73    /// it could be overtaken by a retire that both unpublishes the channel and
74    /// clears this cache, leaving the entry pinning it forever.
75    fn store(
76        &mut self,
77        epoch: u64,
78        ptr: usize,
79        index: u8,
80        value: T,
81        still_current: impl FnOnce() -> bool,
82    ) {
83        if self.epoch == epoch && still_current() {
84            self.entry = Some((ptr, index, value));
85        }
86    }
87
88    fn clear(&mut self) {
89        self.epoch = self.epoch.wrapping_add(1);
90        self.entry = None;
91    }
92
93    /// Drop the entry if it belongs to `ptr`. Always bumps the epoch: the
94    /// caller is retiring that channel, so a store racing this clear must be
95    /// discarded even when nothing (or another channel's entry) is cached yet.
96    fn clear_for(&mut self, ptr: usize) {
97        self.epoch = self.epoch.wrapping_add(1);
98        if self
99            .entry
100            .as_ref()
101            .is_some_and(|(entry_ptr, _, _)| *entry_ptr == ptr)
102        {
103            self.entry = None;
104        }
105    }
106}
107
108static CACHED_FEATURE: std::sync::Mutex<EpochGuarded<Arc<HapticFeedbackFeature>>> =
109    std::sync::Mutex::new(EpochGuarded::new());
110
111/// Snapshot the cache epoch before starting a feature open; pass the result to
112/// [`store_cached_feature`] so a clear that lands mid-open wins over the store.
113fn cache_epoch() -> u64 {
114    CACHED_FEATURE.lock().map_or(0, |guard| guard.epoch)
115}
116
117fn cached_feature(channel: &Arc<HidppChannel>, index: u8) -> Option<Arc<HapticFeedbackFeature>> {
118    let guard = CACHED_FEATURE.lock().ok()?;
119    guard.get(Arc::as_ptr(channel) as usize, index)
120}
121
122/// Cache the freshly-opened feature, unless the enumerator retired its channel
123/// while the open was under way — in either direction, see [`EpochGuarded`].
124fn store_cached_feature(
125    epoch: u64,
126    registry: &ChannelRegistry,
127    shared: &SharedChannel,
128    feature: &Arc<HapticFeedbackFeature>,
129) {
130    if let Ok(mut guard) = CACHED_FEATURE.lock() {
131        guard.store(
132            epoch,
133            Arc::as_ptr(shared.channel()) as usize,
134            shared.device_index(),
135            Arc::clone(feature),
136            || registry.is_current(shared),
137        );
138    }
139}
140
141fn clear_cached_feature() {
142    if let Ok(mut guard) = CACHED_FEATURE.lock() {
143        guard.clear();
144    }
145}
146
147/// Drop the cached haptic feature handle (and with it the `Arc<HidppChannel>`
148/// it pins). MUST be called whenever route resolution fails: the inventory
149/// enumerator only reopens a retired node once every clone of its channel has
150/// dropped (`Arc::strong_count == 1`), and a stale cache entry otherwise
151/// deadlocks recovery — the node can't reopen because the cache pins the old
152/// channel, and the cache is never invalidated because route lookups fail
153/// before any haptic I/O touches it.
154pub fn clear_haptic_feature_cache() {
155    clear_cached_feature();
156}
157
158/// Drop the cached haptic feature handle if it belongs to `channel`.
159///
160/// The enumerator calls this the moment it retires a channel. Clearing only on
161/// route-miss (above) is not enough: a route miss requires a haptic attempt,
162/// and once capture has died no haptic attempt can happen — the Actions Ring
163/// trigger is itself a diverted control that died with capture. The cache
164/// entry then pins the retired channel forever and the node never reopens.
165pub(crate) fn clear_haptic_feature_cache_for(channel: &Arc<HidppChannel>) {
166    if let Ok(mut guard) = CACHED_FEATURE.lock() {
167        guard.clear_for(Arc::as_ptr(channel) as usize);
168    }
169}
170
171/// Ensure the firmware haptic engine is armed: enabled, with a non-zero
172/// intensity. Returns `true` when a repair write was needed.
173///
174/// Nothing else in the stack ever asserts this state — devices historically
175/// inherited it from Logi Options+, and some power transitions clear it, after
176/// which `play` calls are accepted but produce no physical feedback. Callers
177/// arm once per Actions Ring session, before the first hover.
178pub async fn ensure_haptics_armed_on(
179    registry: &ChannelRegistry,
180    shared: &SharedChannel,
181) -> Result<bool, WriteError> {
182    let channel = shared.channel();
183    let index = shared.device_index();
184    let feature = if let Some(feature) = cached_feature(channel, index) {
185        feature
186    } else {
187        let epoch = cache_epoch();
188        let feature = feature_on_channel(channel, index).await?;
189        store_cached_feature(epoch, registry, shared, &feature);
190        feature
191    };
192    let config = feature.get_configuration().await.map_err(|error| {
193        clear_cached_feature();
194        classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
195    })?;
196    let intensity = if config.intensity.get() == 0 {
197        HapticIntensity::new(25).unwrap_or(config.intensity)
198    } else {
199        config.intensity
200    };
201    if config.enabled && intensity == config.intensity {
202        return Ok(false);
203    }
204    feature
205        .set_configuration(true, intensity)
206        .await
207        .map_err(|error| {
208            clear_cached_feature();
209            classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
210        })?;
211    Ok(true)
212}
213
214/// Play a waveform immediately on an open capture channel.
215///
216/// Reuses the cached feature handle when it belongs to this channel (one
217/// round-trip); any error invalidates the cache and the play is retried once
218/// through a fresh open, so a rebuilt channel or stale index self-heals.
219pub async fn play_haptic_on(
220    registry: &ChannelRegistry,
221    shared: &SharedChannel,
222    waveform: HapticWaveform,
223) -> Result<(), WriteError> {
224    let channel = shared.channel();
225    let index = shared.device_index();
226    if let Some(feature) = cached_feature(channel, index) {
227        if feature.play(waveform).await.is_ok() {
228            return Ok(());
229        }
230        clear_cached_feature();
231    }
232    let epoch = cache_epoch();
233    let feature = feature_on_channel(channel, index).await?;
234    let result = feature.play(waveform).await.map_err(|error| {
235        classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
236    });
237    if result.is_ok() {
238        store_cached_feature(epoch, registry, shared, &feature);
239    }
240    result
241}
242
243/// Play a waveform immediately by route.
244pub async fn play_haptic(
245    backend: &dyn HidBackend,
246    route: &DeviceRoute,
247    waveform: HapticWaveform,
248) -> Result<(), WriteError> {
249    let index = route.device_index();
250    with_route(backend, route, move |channel| async move {
251        let feature = feature_on_channel(&channel, index).await?;
252        feature.play(waveform).await.map_err(|error| {
253            classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
254        })
255    })
256    .await
257}
258
259#[cfg(test)]
260mod tests {
261    use super::EpochGuarded;
262
263    /// The registry still publishes the channel the open resolved.
264    const CURRENT: fn() -> bool = || true;
265    /// The registry has dropped it — the enumerator retired the node.
266    const RETIRED: fn() -> bool = || false;
267
268    #[test]
269    fn a_store_started_before_a_clear_is_discarded() {
270        let mut cache = EpochGuarded::new();
271        let epoch = cache.epoch;
272        // The channel retires while the feature open is in flight…
273        cache.clear_for(0xA);
274        // …so the open's belated success must not re-pin the channel.
275        cache.store(epoch, 0xA, 2, "stale", CURRENT);
276        assert_eq!(cache.get(0xA, 2), None);
277    }
278
279    /// The mirror of the case above, and the one an epoch alone cannot see:
280    /// the retire lands *before* the open begins rather than during it.
281    ///
282    /// `hardware::play_haptic` resolves the channel from the registry and then
283    /// awaits its I/O lease. A retire during that wait already ran
284    /// `clear_for`, so by the time `play_haptic_on` snapshots the epoch there
285    /// is nothing left to violate: the play succeeds on the still-writable
286    /// handle and the store would re-pin the retired channel. The enumerator
287    /// reopens a node only once every clone of its channel is gone
288    /// (`Arc::strong_count == 1`), so that entry wedges the node for good —
289    /// the Actions Ring trigger died with capture, so no later haptic can come
290    /// along to invalidate it. Only the registry can still tell.
291    #[test]
292    fn a_store_for_a_channel_retired_before_the_open_is_discarded() {
293        let mut cache = EpochGuarded::new();
294        // The enumerator retires the channel while the play waits for its lease…
295        cache.clear_for(0xA);
296        // …so the open that follows snapshots an epoch that is already current.
297        let epoch = cache.epoch;
298
299        cache.store(epoch, 0xA, 2, "retired", RETIRED);
300
301        assert_eq!(
302            cache.get(0xA, 2),
303            None,
304            "a channel the enumerator has retired must never be cached again"
305        );
306    }
307
308    #[test]
309    fn a_store_with_a_current_epoch_lands() {
310        let mut cache = EpochGuarded::new();
311        cache.store(cache.epoch, 0xA, 2, "fresh", CURRENT);
312        assert_eq!(cache.get(0xA, 2), Some("fresh"));
313        assert_eq!(cache.get(0xB, 2), None);
314        assert_eq!(cache.get(0xA, 3), None);
315    }
316
317    #[test]
318    fn retiring_one_channel_keeps_anothers_entry_but_blocks_stale_stores() {
319        let mut cache = EpochGuarded::new();
320        cache.store(cache.epoch, 0xA, 2, "kept", CURRENT);
321        let epoch = cache.epoch;
322        cache.clear_for(0xB);
323        assert_eq!(cache.get(0xA, 2), Some("kept"));
324        cache.store(epoch, 0xB, 1, "stale", CURRENT);
325        assert_eq!(cache.get(0xB, 1), None);
326    }
327
328    #[test]
329    fn a_full_clear_empties_the_entry_and_blocks_stale_stores() {
330        let mut cache = EpochGuarded::new();
331        let epoch = cache.epoch;
332        cache.store(epoch, 0xA, 2, "cached", CURRENT);
333        cache.clear();
334        assert_eq!(cache.get(0xA, 2), None);
335        cache.store(epoch, 0xA, 2, "stale", CURRENT);
336        assert_eq!(cache.get(0xA, 2), None);
337    }
338}