use std::num::NonZeroU8;
use std::sync::Arc;
use std::time::Duration;
use hidpp::{
channel::HidppChannel,
device::Device,
feature::{
CreatableFeature,
smartshift::{SmartShiftFeature, WheelMode},
smartshift_enhanced::{
SmartShiftEnhancedCapabilities, SmartShiftEnhancedFeature,
SmartShiftEnhancedStatusChange,
},
},
};
use tracing::debug;
use crate::SharedChannel;
use crate::backend::HidBackend;
use crate::channel::route::DeviceRoute;
use openlogi_core::hid::smartshift::{
SmartShiftAutoDisengage, SmartShiftMode, SmartShiftStatus, TunableTorque,
};
use super::{
HidppFeatureErrorKind, HidppOperation, WriteError, classify_hidpp_error, open_feature,
with_route,
};
const TRANSIENT_RETRY_DELAY: Duration = Duration::from_millis(50);
pub(super) fn is_missing_enhanced(err: &WriteError) -> bool {
matches!(
err,
WriteError::FeatureUnsupported { feature_hex } if *feature_hex == 0x2111
)
}
pub(super) fn is_transient_smartshift_error(err: &WriteError) -> bool {
matches!(
err,
WriteError::HidppFeature {
kind: HidppFeatureErrorKind::InvalidArgument
| HidppFeatureErrorKind::Busy
| HidppFeatureErrorKind::HwError,
..
} | WriteError::UnsupportedResponse { .. }
)
}
pub(super) fn status_matches_desired(current: SmartShiftStatus, desired: SmartShiftStatus) -> bool {
current.mode == desired.mode
&& current.auto_disengage == desired.auto_disengage
&& desired
.tunable_torque
.is_none_or(|torque| current.tunable_torque == Some(torque))
}
fn decode_auto_disengage(
value: u8,
feature_hex: u16,
) -> Result<SmartShiftAutoDisengage, WriteError> {
SmartShiftAutoDisengage::try_from(value).map_err(|_| WriteError::UnsupportedResponse {
operation: HidppOperation::ReadSmartShift,
feature_hex,
})
}
fn confirm_write(confirmed: bool, feature_hex: u16) -> Result<(), WriteError> {
if confirmed {
Ok(())
} else {
Err(WriteError::UnsupportedResponse {
operation: HidppOperation::WriteSmartShift,
feature_hex,
})
}
}
fn decode_tunable_torque(value: u8) -> Result<Option<TunableTorque>, WriteError> {
if value == 0 {
return Ok(None);
}
TunableTorque::try_from(value)
.map(Some)
.map_err(|_| WriteError::UnsupportedResponse {
operation: HidppOperation::ReadSmartShift,
feature_hex: SmartShiftEnhancedFeature::ID,
})
}
pub(super) fn wheel_mode_to_smartshift(wheel: WheelMode) -> SmartShiftMode {
if matches!(wheel, WheelMode::Freespin) {
SmartShiftMode::Free
} else {
SmartShiftMode::Ratchet
}
}
pub(super) fn smartshift_to_wheel(mode: SmartShiftMode) -> WheelMode {
match mode {
SmartShiftMode::Free => WheelMode::Freespin,
SmartShiftMode::Ratchet => WheelMode::Ratchet,
}
}
enum SmartShift {
Enhanced(Arc<SmartShiftEnhancedFeature>),
Legacy(Arc<SmartShiftFeature>),
}
impl SmartShift {
async fn open(device: &mut Device) -> Result<Self, WriteError> {
match open_feature::<SmartShiftEnhancedFeature>(device).await {
Ok(feature) => Ok(Self::Enhanced(feature)),
Err(err) if is_missing_enhanced(&err) => {
match open_feature::<SmartShiftEnhancedFeature>(device).await {
Ok(feature) => Ok(Self::Enhanced(feature)),
Err(err) if is_missing_enhanced(&err) => {
let feature = open_feature::<SmartShiftFeature>(device).await?;
Ok(Self::Legacy(feature))
}
Err(err) => Err(err),
}
}
Err(err) => Err(err),
}
}
async fn status(&self) -> Result<SmartShiftStatus, WriteError> {
match self {
Self::Enhanced(feature) => {
let status = feature.get_ratchet_control_mode().await.map_err(|e| {
classify_hidpp_error(
e,
HidppOperation::ReadSmartShift,
SmartShiftEnhancedFeature::ID,
)
})?;
Ok(SmartShiftStatus {
mode: wheel_mode_to_smartshift(status.wheel_mode),
auto_disengage: decode_auto_disengage(
status.auto_disengage,
SmartShiftEnhancedFeature::ID,
)?,
tunable_torque: decode_tunable_torque(status.current_tunable_torque)?,
})
}
Self::Legacy(feature) => {
let rcm = feature.get_ratchet_control_mode().await.map_err(|e| {
classify_hidpp_error(e, HidppOperation::ReadSmartShift, SmartShiftFeature::ID)
})?;
Ok(SmartShiftStatus {
mode: wheel_mode_to_smartshift(rcm.wheel_mode),
auto_disengage: decode_auto_disengage(
rcm.auto_disengage,
SmartShiftFeature::ID,
)?,
tunable_torque: None,
})
}
}
}
async fn set_status(&self, status: SmartShiftStatus) -> Result<(), WriteError> {
let SmartShiftStatus {
mode,
auto_disengage,
tunable_torque,
} = status;
let wheel_mode = smartshift_to_wheel(mode);
let auto_disengage = NonZeroU8::from(auto_disengage);
match self {
Self::Enhanced(feature) => {
let tunable_torque = writable_torque(feature, tunable_torque).await?;
let applied = feature
.set_ratchet_control_mode(SmartShiftEnhancedStatusChange {
wheel_mode: Some(wheel_mode),
auto_disengage: Some(auto_disengage),
tunable_torque,
})
.await
.map_err(|e| {
classify_hidpp_error(
e,
HidppOperation::WriteSmartShift,
SmartShiftEnhancedFeature::ID,
)
})?;
confirm_write(
applied.wheel_mode == wheel_mode
&& applied.auto_disengage == auto_disengage.get()
&& tunable_torque
.is_none_or(|torque| applied.current_tunable_torque == torque.get()),
SmartShiftEnhancedFeature::ID,
)
}
Self::Legacy(feature) => {
let applied = feature
.set_ratchet_control_mode(Some(wheel_mode), Some(auto_disengage), None)
.await
.map_err(|e| {
classify_hidpp_error(
e,
HidppOperation::WriteSmartShift,
SmartShiftFeature::ID,
)
})?;
confirm_write(
applied.wheel_mode == wheel_mode
&& applied.auto_disengage == auto_disengage.get(),
SmartShiftFeature::ID,
)
}
}
}
async fn set_sensitivity(&self, value: SmartShiftAutoDisengage) -> Result<(), WriteError> {
let current = self.status().await?;
self.set_status(SmartShiftStatus {
auto_disengage: value,
tunable_torque: None,
..current
})
.await
}
}
async fn writable_torque(
feature: &SmartShiftEnhancedFeature,
torque: Option<TunableTorque>,
) -> Result<Option<NonZeroU8>, WriteError> {
let Some(torque) = torque else {
return Ok(None);
};
let info = feature.get_capabilities().await.map_err(|e| {
classify_hidpp_error(
e,
HidppOperation::ReadSmartShift,
SmartShiftEnhancedFeature::ID,
)
})?;
if info
.capabilities
.contains(SmartShiftEnhancedCapabilities::TUNABLE_TORQUE)
{
Ok(Some(NonZeroU8::from(torque)))
} else {
debug!(
%torque,
"device does not advertise tunable torque; leaving it unchanged"
);
Ok(None)
}
}
pub async fn get_smartshift_status(
backend: &dyn HidBackend,
route: &DeviceRoute,
) -> Result<SmartShiftStatus, WriteError> {
let index = route.device_index();
with_route(backend, route, move |channel| async move {
get_smartshift_status_on_channel(&channel, index).await
})
.await
}
pub(super) async fn get_smartshift_status_on_channel(
channel: &Arc<HidppChannel>,
index: u8,
) -> Result<SmartShiftStatus, WriteError> {
let mut device = Device::new(Arc::clone(channel), index)
.await
.map_err(|_| WriteError::DeviceUnreachable { index })?;
let smartshift = SmartShift::open(&mut device).await?;
smartshift.status().await
}
pub async fn set_smartshift_sensitivity(
backend: &dyn HidBackend,
route: &DeviceRoute,
value: SmartShiftAutoDisengage,
) -> Result<SmartShiftStatus, WriteError> {
let index = route.device_index();
with_route(backend, route, move |channel| async move {
let mut device = Device::new(Arc::clone(&channel), index)
.await
.map_err(|_| WriteError::DeviceUnreachable { index })?;
let smartshift = SmartShift::open(&mut device).await?;
smartshift.set_sensitivity(value).await?;
smartshift.status().await
})
.await
}
pub async fn toggle_smartshift(
backend: &dyn HidBackend,
route: &DeviceRoute,
) -> Result<SmartShiftMode, WriteError> {
let index = route.device_index();
with_route(backend, route, move |channel| async move {
toggle_smartshift_on_channel(&channel, index).await
})
.await
}
pub(super) async fn toggle_smartshift_on_channel(
channel: &Arc<HidppChannel>,
index: u8,
) -> Result<SmartShiftMode, WriteError> {
let mut device = Device::new(Arc::clone(channel), index)
.await
.map_err(|_| WriteError::DeviceUnreachable { index })?;
let (smartshift, target) = retry_transient_toggle(index, async || {
let smartshift = SmartShift::open(&mut device).await?;
let status = smartshift.status().await?;
let target = SmartShiftStatus {
mode: status.mode.flipped(),
tunable_torque: None,
..status
};
Ok((smartshift, target))
})
.await?;
retry_transient_toggle(index, async || smartshift.set_status(target).await).await?;
debug!(index, next = ?target.mode, "wrote SmartShift mode");
Ok(target.mode)
}
async fn retry_transient_toggle<T>(
index: u8,
mut step: impl AsyncFnMut() -> Result<T, WriteError>,
) -> Result<T, WriteError> {
match step().await {
Err(err) if is_transient_smartshift_error(&err) => {
debug!(
index,
error = ?err,
"SmartShift toggle hit a transient error; retrying once"
);
tokio::time::sleep(TRANSIENT_RETRY_DELAY).await;
step().await
}
result => result,
}
}
pub async fn set_smartshift(
backend: &dyn HidBackend,
route: &DeviceRoute,
status: SmartShiftStatus,
) -> Result<(), WriteError> {
let index = route.device_index();
with_route(backend, route, move |channel| async move {
set_smartshift_on_channel(&channel, index, status).await
})
.await
}
pub(super) async fn set_smartshift_on_channel(
channel: &Arc<HidppChannel>,
index: u8,
desired: SmartShiftStatus,
) -> Result<(), WriteError> {
let mut device = Device::new(Arc::clone(channel), index)
.await
.map_err(|_| WriteError::DeviceUnreachable { index })?;
let smartshift = SmartShift::open(&mut device).await?;
let current = smartshift.status().await.ok();
if current.is_some_and(|current| status_matches_desired(current, desired)) {
debug!(
index,
status = ?desired,
"SmartShift already matches config; skipping write"
);
return Ok(());
}
let write = SmartShiftStatus {
tunable_torque: desired
.tunable_torque
.filter(|torque| current.is_none_or(|current| current.tunable_torque != Some(*torque))),
..desired
};
match smartshift.set_status(write).await {
Ok(()) => {
debug!(
index,
status = ?desired,
"wrote SmartShift config"
);
Ok(())
}
Err(err) if is_transient_smartshift_error(&err) => {
debug!(
index,
error = ?err,
"SmartShift write hit a transient error; retrying once"
);
tokio::time::sleep(TRANSIENT_RETRY_DELAY).await;
let smartshift = SmartShift::open(&mut device).await?;
smartshift.set_status(write).await?;
debug!(
index,
status = ?desired,
"wrote SmartShift config"
);
Ok(())
}
Err(err) => Err(err),
}
}
pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result<SmartShiftMode, WriteError> {
toggle_smartshift_on_channel(shared.channel(), shared.device_index()).await
}
pub async fn get_smartshift_status_on(
shared: &SharedChannel,
) -> Result<SmartShiftStatus, WriteError> {
get_smartshift_status_on_channel(shared.channel(), shared.device_index()).await
}
pub async fn set_smartshift_on(
shared: &SharedChannel,
status: SmartShiftStatus,
) -> Result<(), WriteError> {
set_smartshift_on_channel(shared.channel(), shared.device_index(), status).await
}
#[cfg(test)]
mod tests;