use std::sync::{Arc, Mutex, Weak};
use hidpp::{
channel::HidppChannel,
device::Device,
feature::{
CreatableFeature,
haptic_feedback::{HapticFeedbackFeature, HapticIntensity, HapticWaveform},
},
};
use crate::SharedChannel;
use crate::backend::HidBackend;
use crate::channel::route::DeviceRoute;
use super::{HidppOperation, WriteError, classify_hidpp_error, with_route};
async fn feature_on_channel(
channel: &Arc<HidppChannel>,
device_index: u8,
) -> Result<(Arc<HapticFeedbackFeature>, u8), WriteError> {
let mut device = Device::new(Arc::clone(channel), device_index)
.await
.map_err(|_| WriteError::DeviceUnreachable {
index: device_index,
})?;
let info = device
.root()
.get_feature(HapticFeedbackFeature::ID)
.await
.map_err(|e| {
classify_hidpp_error(e, HidppOperation::ResolveFeature, HapticFeedbackFeature::ID)
})?
.ok_or(WriteError::FeatureUnsupported {
feature_hex: HapticFeedbackFeature::ID,
})?;
Ok((
device.add_feature::<HapticFeedbackFeature>(info.index),
info.index,
))
}
struct FeatureLocation {
channel: Weak<HidppChannel>,
device_index: u8,
feature_index: u8,
}
static CACHED_LOCATION: Mutex<Option<FeatureLocation>> = Mutex::new(None);
fn cached_feature(channel: &Arc<HidppChannel>, index: u8) -> Option<Arc<HapticFeedbackFeature>> {
let guard = CACHED_LOCATION.lock().ok()?;
let location = guard.as_ref()?;
(location.device_index == index
&& location
.channel
.upgrade()
.is_some_and(|live| Arc::ptr_eq(&live, channel)))
.then(|| {
Arc::new(<HapticFeedbackFeature as CreatableFeature>::new(
Arc::clone(channel),
index,
location.feature_index,
))
})
}
fn store_cached_location(channel: &Arc<HidppChannel>, device_index: u8, feature_index: u8) {
if let Ok(mut guard) = CACHED_LOCATION.lock() {
*guard = Some(FeatureLocation {
channel: Arc::downgrade(channel),
device_index,
feature_index,
});
}
}
fn clear_cached_location() {
if let Ok(mut guard) = CACHED_LOCATION.lock() {
*guard = None;
}
}
pub async fn ensure_haptics_armed_on(shared: &SharedChannel) -> Result<bool, WriteError> {
let channel = shared.channel();
let index = shared.device_index();
let (feature, feature_index) = feature_on_channel(channel, index).await?;
store_cached_location(channel, index, feature_index);
let config = feature.get_configuration().await.map_err(|error| {
clear_cached_location();
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})?;
let intensity = if config.intensity.get() == 0 {
HapticIntensity::new(25).unwrap_or(config.intensity)
} else {
config.intensity
};
if config.enabled && intensity == config.intensity {
return Ok(false);
}
feature
.set_configuration(true, intensity)
.await
.map_err(|error| {
clear_cached_location();
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})?;
Ok(true)
}
pub async fn play_haptic_on(
shared: &SharedChannel,
waveform: HapticWaveform,
) -> Result<(), WriteError> {
let channel = shared.channel();
let index = shared.device_index();
if let Some(feature) = cached_feature(channel, index) {
if feature.play(waveform).await.is_ok() {
return Ok(());
}
clear_cached_location();
}
let (feature, feature_index) = feature_on_channel(channel, index).await?;
let result = feature.play(waveform).await.map_err(|error| {
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
});
if result.is_ok() {
store_cached_location(channel, index, feature_index);
}
result
}
pub async fn play_haptic(
backend: &dyn HidBackend,
route: &DeviceRoute,
waveform: HapticWaveform,
) -> Result<(), WriteError> {
let index = route.device_index();
with_route(backend, route, move |channel| async move {
let (feature, _) = feature_on_channel(&channel, index).await?;
feature.play(waveform).await.map_err(|error| {
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel::scripted::ScriptedRawHidChannel;
async fn scripted_channel() -> Arc<HidppChannel> {
let (raw, _reports) = ScriptedRawHidChannel::with_responder(|_| None);
Arc::new(
HidppChannel::from_raw_channel(raw)
.await
.expect("the scripted channel must support HID++"),
)
}
#[tokio::test]
async fn the_cache_matches_exactly_the_channel_it_was_stored_for() {
let channel = scripted_channel().await;
store_cached_location(&channel, 2, 7);
assert!(
cached_feature(&channel, 2).is_some(),
"the stored channel at the stored index is a hit"
);
assert!(
cached_feature(&channel, 3).is_none(),
"another device index is a miss"
);
let other = scripted_channel().await;
assert!(
cached_feature(&other, 2).is_none(),
"another channel is a miss — identity, not address, decides"
);
drop(channel);
let replacement = scripted_channel().await;
assert!(
cached_feature(&replacement, 2).is_none(),
"a dead entry must not match any later channel"
);
clear_cached_location();
assert!(cached_feature(&replacement, 2).is_none());
}
}