use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use hidpp::{
channel::HidppChannel,
device::Device,
feature::{
CreatableFeature,
haptic_feedback::{
HapticConfigFlags, HapticFeedbackFeature, HapticStatus, HapticWaveform,
SupportedWaveforms,
},
},
};
use tracing::debug;
use crate::SharedChannel;
use crate::backend::HidBackend;
use crate::channel::route::DeviceRoute;
use super::{HidppOperation, WriteError, classify_hidpp_error, with_route};
const STATUS_READ_TIMEOUT: Duration = Duration::from_millis(250);
struct Haptics {
feature: Arc<HapticFeedbackFeature>,
feature_index: u8,
waveforms: SupportedWaveforms,
status: Option<HapticStatus>,
}
impl Haptics {
fn check(&self, waveform: HapticWaveform) -> Result<(), WriteError> {
ensure_advertised(self.waveforms, waveform)?;
ensure_ready(self.status)
}
}
async fn open_haptics(
channel: &Arc<HidppChannel>,
device_index: u8,
) -> Result<Haptics, 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,
})?;
let feature = device.add_feature::<HapticFeedbackFeature>(info.index);
let capabilities = feature.get_capabilities().await.map_err(|error| {
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})?;
let status = match tokio::time::timeout(STATUS_READ_TIMEOUT, feature.get_haptic_status()).await
{
Ok(Ok(status)) => Some(status),
Ok(Err(error)) => {
debug!(%error, "haptic status unreadable; playing without it");
None
}
Err(_) => {
debug!(timeout = ?STATUS_READ_TIMEOUT, "haptic status read timed out; playing without it");
None
}
};
Ok(Haptics {
feature,
feature_index: info.index,
waveforms: capabilities.waveforms,
status,
})
}
fn ensure_advertised(
waveforms: SupportedWaveforms,
waveform: HapticWaveform,
) -> Result<(), WriteError> {
if waveforms.supports(waveform) {
Ok(())
} else {
Err(WriteError::HapticWaveformUnsupported {
waveform: waveform.into(),
})
}
}
fn ensure_ready(status: Option<HapticStatus>) -> Result<(), WriteError> {
match status {
Some(status) if !status.is_ready() => Err(WriteError::HapticsUnavailable {
status: status.raw(),
}),
_ => Ok(()),
}
}
struct FeatureLocation {
channel: Weak<HidppChannel>,
device_index: u8,
feature_index: u8,
waveforms: SupportedWaveforms,
status: Option<HapticStatus>,
}
static CACHED_LOCATION: Mutex<Option<FeatureLocation>> = Mutex::new(None);
fn cached_feature(channel: &Arc<HidppChannel>, index: u8) -> Option<Haptics> {
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(|| Haptics {
feature: Arc::new(<HapticFeedbackFeature as CreatableFeature>::new(
Arc::clone(channel),
index,
location.feature_index,
)),
feature_index: location.feature_index,
waveforms: location.waveforms,
status: location.status,
})
}
fn store_cached_location(channel: &Arc<HidppChannel>, device_index: u8, haptics: &Haptics) {
if let Ok(mut guard) = CACHED_LOCATION.lock() {
*guard = Some(FeatureLocation {
channel: Arc::downgrade(channel),
device_index,
feature_index: haptics.feature_index,
waveforms: haptics.waveforms,
status: haptics.status,
});
}
}
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 haptics = open_haptics(channel, index).await?;
store_cached_location(channel, index, &haptics);
let feature = haptics.feature;
let config = feature.get_configuration().await.map_err(|error| {
clear_cached_location();
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})?;
if config.flags.contains(HapticConfigFlags::ENABLED) || config.intensity.get() == 0 {
return Ok(false);
}
let applied = feature
.set_configuration(config.flags | HapticConfigFlags::ENABLED, config.intensity)
.await
.map_err(|error| {
clear_cached_location();
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})?;
if !applied.flags.contains(HapticConfigFlags::ENABLED) {
clear_cached_location();
return Err(WriteError::UnsupportedResponse {
operation: HidppOperation::PlayHaptic,
feature_hex: 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(haptics) = cached_feature(channel, index) {
haptics.check(waveform)?;
if haptics.feature.play(waveform).await.is_ok() {
return Ok(());
}
clear_cached_location();
}
let haptics = open_haptics(channel, index).await?;
store_cached_location(channel, index, &haptics);
haptics.check(waveform)?;
haptics.feature.play(waveform).await.map_err(|error| {
clear_cached_location();
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})
}
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 haptics = open_haptics(&channel, index).await?;
haptics.check(waveform)?;
haptics.feature.play(waveform).await.map_err(|error| {
classify_hidpp_error(error, HidppOperation::PlayHaptic, HapticFeedbackFeature::ID)
})
})
.await
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use std::sync::Mutex as StdMutex;
use super::*;
use crate::channel::scripted::{ScriptedRawHidChannel, scripted_channel};
static CACHE_SLOT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const HAPTIC_INDEX: u8 = 0x0b;
const RING_WAVEFORMS: u32 = (1 << 1) | (1 << 4);
async fn silent_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++"),
)
}
fn stateful_haptics(
initial: [u8; 3],
firmware: fn(&mut [u8; 3]),
waveforms: u32,
) -> (ScriptedRawHidChannel, crate::replay::ReplayChannelHandle) {
haptics_reporting(initial, firmware, waveforms, StatusReply::Status(0))
}
#[derive(Clone, Copy)]
enum StatusReply {
Status(u8),
Error,
Silent,
}
fn haptics_reporting(
initial: [u8; 3],
firmware: fn(&mut [u8; 3]),
waveforms: u32,
status: StatusReply,
) -> (ScriptedRawHidChannel, crate::replay::ReplayChannelHandle) {
let state = StdMutex::new(initial);
ScriptedRawHidChannel::with_dynamic_responder(move |request| {
if request.len() < 7 || request[0] != 0x10 {
return None;
}
if (request[2], request[3] >> 4) == (HAPTIC_INDEX, 0x03) {
let raw = match status {
StatusReply::Status(raw) => raw,
StatusReply::Error => {
return Some(vec![0x10, request[1], 0xff, request[2], request[3], 7, 0]);
}
StatusReply::Silent => return None,
};
let mut response = request[..4].to_vec();
response.extend_from_slice(&[raw, 0, 0]);
return Some(response);
}
if (request[2], request[3] >> 4) == (HAPTIC_INDEX, 0x00) {
let mut response = vec![0x11, request[1], request[2], request[3]];
response.extend_from_slice(&[0; 4]);
response.extend_from_slice(&waveforms.to_be_bytes());
response.resize(20, 0);
return Some(response);
}
let mut payload = [0u8; 3];
match (request[2], request[3] >> 4) {
(0x00, 0x01) => payload[0] = 4,
(0x00, 0x00) => {
if u16::from_be_bytes([request[4], request[5]]) == HapticFeedbackFeature::ID {
payload[0] = HAPTIC_INDEX;
}
}
(HAPTIC_INDEX, 0x01) => payload = *state.lock().expect("haptic state"),
(HAPTIC_INDEX, 0x02) => {
let mut current = state.lock().expect("haptic state");
current[..2].copy_from_slice(&request[4..6]);
firmware(&mut current);
payload = *current;
}
(HAPTIC_INDEX, 0x04) => {}
_ => return None,
}
let mut response = request[..4].to_vec();
response.extend_from_slice(&payload);
Some(response)
})
}
async fn arm_with(
initial: [u8; 3],
firmware: fn(&mut [u8; 3]),
) -> (Result<bool, WriteError>, Vec<[u8; 3]>, bool) {
let (raw, handle) = stateful_haptics(initial, firmware, RING_WAVEFORMS);
let shared = shared_channel(raw).await;
let result = ensure_haptics_armed_on(&shared).await;
let writes = handle
.written_reports()
.into_iter()
.filter(|report| report[2] == HAPTIC_INDEX && report[3] >> 4 == 0x02)
.map(|report| [report[4], report[5], report[6]])
.collect();
let cached = cached_feature(shared.channel(), shared.device_index()).is_some();
(result, writes, cached)
}
async fn shared_channel(raw: ScriptedRawHidChannel) -> SharedChannel {
SharedChannel::new(
scripted_channel(raw).await,
DeviceRoute::Direct {
vendor_id: 0x046d,
product_id: 0xb042,
},
)
}
fn status_reads(handle: &crate::replay::ReplayChannelHandle) -> usize {
handle
.written_reports()
.iter()
.filter(|report| report[2] == HAPTIC_INDEX && report[3] >> 4 == 0x03)
.count()
}
fn plays(handle: &crate::replay::ReplayChannelHandle) -> Vec<u8> {
handle
.written_reports()
.into_iter()
.filter(|report| report[2] == HAPTIC_INDEX && report[3] >> 4 == 0x04)
.map(|report| report[4])
.collect()
}
async fn arm(initial: [u8; 3]) -> (bool, Vec<[u8; 3]>) {
let (result, writes, _) = arm_with(initial, |_| {}).await;
let repaired = result.expect("the scripted device answers every haptic call");
(repaired, writes)
}
#[tokio::test]
async fn arming_sets_only_the_enable_bit() {
let _slot = CACHE_SLOT.lock().await;
assert_eq!(arm([0x82, 60, 0x34]).await, (true, vec![[0x83, 60, 0]]));
}
#[tokio::test]
async fn arming_leaves_a_zero_intensity_and_an_armed_engine_alone() {
let _slot = CACHE_SLOT.lock().await;
assert_eq!(arm([0x00, 0, 0x34]).await, (false, vec![]));
assert_eq!(arm([0x03, 40, 0x34]).await, (false, vec![]));
}
#[tokio::test]
async fn arming_fails_when_the_reply_shows_the_engine_still_off() {
let _slot = CACHE_SLOT.lock().await;
let (result, writes, cached) = arm_with([0x82, 60, 0x34], |state| state[0] &= !0x01).await;
assert_matches!(
result,
Err(WriteError::UnsupportedResponse {
operation: HidppOperation::PlayHaptic,
feature_hex: HapticFeedbackFeature::ID,
})
);
assert_eq!(writes, vec![[0x83, 60, 0]], "the repair was attempted once");
assert!(!cached, "a refused repair leaves no cached location behind");
}
#[tokio::test]
async fn arming_accepts_a_firmware_normalized_intensity() {
let _slot = CACHE_SLOT.lock().await;
let (result, writes, cached) = arm_with([0x82, 63, 0x34], |state| {
state[1] = 60;
state[0] &= !0x02;
})
.await;
assert_matches!(result, Ok(true));
assert_eq!(writes, vec![[0x83, 63, 0]]);
assert!(
cached,
"a confirmed repair keeps the location for the plays"
);
}
#[tokio::test]
async fn the_cache_matches_exactly_the_channel_it_was_stored_for() {
let _slot = CACHE_SLOT.lock().await;
let channel = silent_channel().await;
let haptics = Haptics {
feature: Arc::new(HapticFeedbackFeature::new(Arc::clone(&channel), 2, 7)),
feature_index: 7,
waveforms: SupportedWaveforms::empty(),
status: None,
};
store_cached_location(&channel, 2, &haptics);
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 = silent_channel().await;
assert!(
cached_feature(&other, 2).is_none(),
"another channel is a miss — identity, not address, decides"
);
drop(channel);
let replacement = silent_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());
}
#[tokio::test]
async fn a_waveform_the_device_does_not_advertise_is_never_sent() {
let _slot = CACHE_SLOT.lock().await;
clear_cached_location();
let (raw, handle) = stateful_haptics([0x03, 40, 0x34], |_| {}, 1 << 4);
let shared = shared_channel(raw).await;
assert_matches!(
play_haptic_on(&shared, HapticWaveform::DampStateChange).await,
Err(WriteError::HapticWaveformUnsupported { waveform: 1 })
);
assert_eq!(plays(&handle), Vec::<u8>::new());
assert!(
cached_feature(shared.channel(), shared.device_index()).is_some(),
"a refusal is a fact about the device; the location stays cached"
);
let sent = handle.written_reports().len();
assert_matches!(
play_haptic_on(&shared, HapticWaveform::DampStateChange).await,
Err(WriteError::HapticWaveformUnsupported { waveform: 1 })
);
assert_eq!(handle.written_reports().len(), sent);
assert_matches!(
play_haptic_on(&shared, HapticWaveform::SubtleCollision).await,
Ok(())
);
assert_eq!(plays(&handle), vec![4]);
clear_cached_location();
}
#[tokio::test]
async fn unnamed_capability_bits_advertise_no_ring_waveform() {
let _slot = CACHE_SLOT.lock().await;
clear_cached_location();
let (raw, handle) = stateful_haptics([0x03, 40, 0x34], |_| {}, !RING_WAVEFORMS);
let shared = shared_channel(raw).await;
for waveform in [
HapticWaveform::DampStateChange,
HapticWaveform::SubtleCollision,
] {
assert_matches!(
play_haptic_on(&shared, waveform).await,
Err(WriteError::HapticWaveformUnsupported { .. })
);
}
assert_eq!(plays(&handle), Vec::<u8>::new());
clear_cached_location();
}
#[tokio::test]
async fn an_armed_session_plays_both_ring_waveforms_from_the_cached_mask() {
let _slot = CACHE_SLOT.lock().await;
let (raw, handle) = stateful_haptics([0x03, 40, 0x34], |_| {}, RING_WAVEFORMS);
let shared = shared_channel(raw).await;
assert_matches!(ensure_haptics_armed_on(&shared).await, Ok(false));
let capability_reads = |handle: &crate::replay::ReplayChannelHandle| {
handle
.written_reports()
.iter()
.filter(|report| report[2] == HAPTIC_INDEX && report[3] >> 4 == 0x00)
.count()
};
assert_eq!(capability_reads(&handle), 1, "arming reads the mask once");
for waveform in [
HapticWaveform::SubtleCollision,
HapticWaveform::DampStateChange,
] {
assert_matches!(play_haptic_on(&shared, waveform).await, Ok(()));
}
assert_eq!(plays(&handle), vec![4, 1]);
assert_eq!(
capability_reads(&handle),
1,
"the plays reuse the mask cached with the location"
);
clear_cached_location();
}
#[tokio::test]
async fn a_non_zero_status_refuses_every_play_before_it_is_sent() {
let _slot = CACHE_SLOT.lock().await;
clear_cached_location();
let (raw, handle) = haptics_reporting(
[0x03, 40, 0x34],
|_| {},
RING_WAVEFORMS,
StatusReply::Status(2),
);
let shared = shared_channel(raw).await;
assert_matches!(
play_haptic_on(&shared, HapticWaveform::SubtleCollision).await,
Err(WriteError::HapticsUnavailable { status: 2 })
);
assert_eq!(plays(&handle), Vec::<u8>::new());
assert!(
cached_feature(shared.channel(), shared.device_index()).is_some(),
"a refusal is a fact about the device; the location stays cached"
);
let sent = handle.written_reports().len();
assert_matches!(
play_haptic_on(&shared, HapticWaveform::DampStateChange).await,
Err(WriteError::HapticsUnavailable { status: 2 })
);
assert_eq!(handle.written_reports().len(), sent);
clear_cached_location();
}
#[tokio::test]
async fn an_armed_session_plays_by_the_status_read_when_it_starts() {
let _slot = CACHE_SLOT.lock().await;
let (raw, handle) = stateful_haptics([0x03, 40, 0x34], |_| {}, RING_WAVEFORMS);
let shared = shared_channel(raw).await;
assert_matches!(ensure_haptics_armed_on(&shared).await, Ok(false));
assert_eq!(status_reads(&handle), 1, "arming reads the status once");
for waveform in [
HapticWaveform::SubtleCollision,
HapticWaveform::DampStateChange,
] {
assert_matches!(play_haptic_on(&shared, waveform).await, Ok(()));
}
assert_eq!(plays(&handle), vec![4, 1]);
assert_eq!(
status_reads(&handle),
1,
"the plays reuse the status cached with the location"
);
let (raw, handle) = haptics_reporting(
[0x03, 40, 0x34],
|_| {},
RING_WAVEFORMS,
StatusReply::Status(1),
);
let shared = shared_channel(raw).await;
assert_matches!(ensure_haptics_armed_on(&shared).await, Ok(false));
assert_matches!(
play_haptic_on(&shared, HapticWaveform::SubtleCollision).await,
Err(WriteError::HapticsUnavailable { status: 1 })
);
assert_eq!(plays(&handle), Vec::<u8>::new());
clear_cached_location();
}
#[tokio::test]
async fn a_device_that_does_not_answer_the_status_still_plays() {
let _slot = CACHE_SLOT.lock().await;
clear_cached_location();
let (raw, handle) =
haptics_reporting([0x03, 40, 0x34], |_| {}, RING_WAVEFORMS, StatusReply::Error);
let shared = shared_channel(raw).await;
assert_matches!(
play_haptic_on(&shared, HapticWaveform::SubtleCollision).await,
Ok(())
);
assert_eq!(status_reads(&handle), 1, "the status was asked for");
assert_eq!(plays(&handle), vec![4]);
clear_cached_location();
}
#[tokio::test(start_paused = true)]
async fn a_status_that_never_arrives_stops_neither_arming_nor_play() {
let _slot = CACHE_SLOT.lock().await;
clear_cached_location();
let (raw, handle) = haptics_reporting(
[0x03, 40, 0x34],
|_| {},
RING_WAVEFORMS,
StatusReply::Silent,
);
let shared = shared_channel(raw).await;
let started = std::time::Instant::now();
let started_paused = tokio::time::Instant::now();
assert_matches!(ensure_haptics_armed_on(&shared).await, Ok(false));
assert!(
started.elapsed() < Duration::from_secs(1),
"arming does not wait for the channel's response timeout, took {:?}",
started.elapsed()
);
assert!(
started_paused.elapsed() < Duration::from_millis(500),
"the status deadline leaves most of the agent's 1 s arming budget, took {:?}",
started_paused.elapsed()
);
assert_eq!(status_reads(&handle), 1, "arming asked for the status");
assert_matches!(
play_haptic_on(&shared, HapticWaveform::SubtleCollision).await,
Ok(())
);
assert_matches!(
play_haptic_on(&shared, HapticWaveform::SubtleCollision).await,
Ok(())
);
assert_eq!(
status_reads(&handle),
1,
"the plays reuse the location cached despite the missing status"
);
assert_eq!(plays(&handle), vec![4, 4]);
clear_cached_location();
}
}