use std::sync::Arc;
use hidpp::{
channel::HidppChannel,
device::Device,
feature::hires_wheel::HiResWheelFeature,
feature::{
CreatableFeature,
battery_status::BatteryStatusFeature,
battery_voltage::BatteryVoltageFeature,
device_information::DeviceInformationFeature,
device_type_and_name::DeviceTypeAndNameFeature,
gestures2::Gestures2Feature,
reprog_controls::{ReprogControlsFeature, control_ids},
unified_battery::UnifiedBatteryFeature,
},
};
use openlogi_core::device::{
BatteryInfo, BatteryLevel, Capabilities, DeviceKind, DeviceModelInfo, DeviceTransports,
};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::mappings::{
legacy_battery_level_from_percentage, map_battery_level, map_battery_status, map_device_type,
map_legacy_battery_status, map_voltage_battery_status, normalize_serial_number,
voltage_battery_percentage,
};
#[derive(Default, Clone, Serialize, Deserialize)]
pub(super) struct ProbedFeatures {
pub(super) battery: Option<BatteryInfo>,
pub(super) model_info: Option<DeviceModelInfo>,
pub(super) kind: Option<DeviceKind>,
pub(super) marketing_name: Option<String>,
pub(super) capabilities: Option<Capabilities>,
pub(super) identity_incomplete: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(super) enum BatteryProbe {
Unified(u8),
Legacy(u8),
Voltage(u8),
}
pub(super) async fn read_battery(
channel: &Arc<HidppChannel>,
slot: u8,
probe: BatteryProbe,
) -> Option<BatteryInfo> {
match probe {
BatteryProbe::Unified(feature_index) => {
let feature = UnifiedBatteryFeature::new(Arc::clone(channel), slot, feature_index);
feature
.get_battery_info()
.await
.ok()
.map(|info| BatteryInfo {
percentage: info.charging_percentage,
level: map_battery_level(info.level),
status: map_battery_status(info.status),
})
}
BatteryProbe::Legacy(feature_index) => {
let feature = BatteryStatusFeature::new(Arc::clone(channel), slot, feature_index);
feature
.get_battery_level_status()
.await
.ok()
.map(|info| BatteryInfo {
percentage: info.discharge_level,
level: legacy_battery_level_from_percentage(info.discharge_level),
status: map_legacy_battery_status(info.status),
})
}
BatteryProbe::Voltage(feature_index) => {
let feature = BatteryVoltageFeature::new(Arc::clone(channel), slot, feature_index);
feature.get_battery_info().await.ok().map(|info| {
let percentage = voltage_battery_percentage(info.voltage_mv);
BatteryInfo {
percentage,
level: if info.critical {
BatteryLevel::Critical
} else {
legacy_battery_level_from_percentage(percentage)
},
status: map_voltage_battery_status(info.status),
}
})
}
}
}
pub(super) fn battery_feature_index(ids: impl IntoIterator<Item = u16>) -> Option<BatteryProbe> {
let mut legacy = None;
let mut voltage = None;
for (pos, id) in ids.into_iter().enumerate() {
let Ok(index) = u8::try_from(pos + 1) else {
break;
};
if id == UnifiedBatteryFeature::ID {
return Some(BatteryProbe::Unified(index));
}
if id == BatteryStatusFeature::ID && legacy.is_none() {
legacy = Some(BatteryProbe::Legacy(index));
}
if id == BatteryVoltageFeature::ID && voltage.is_none() {
voltage = Some(BatteryProbe::Voltage(index));
}
}
legacy.or(voltage)
}
async fn read_marketing_identity(
device: &Device,
slot: u8,
) -> (Option<DeviceKind>, Option<String>) {
let Some(feature) = device.get_feature::<DeviceTypeAndNameFeature>() else {
return (None, None);
};
let kind = match feature.get_device_type().await {
Ok(ty) => Some(map_device_type(ty)),
Err(e) => {
debug!(slot, error = ?e, "DeviceType read failed");
None
}
};
let name = match feature.get_whole_device_name().await {
Ok(name) if !name.trim().is_empty() => Some(name),
Ok(_) => None,
Err(e) => {
debug!(slot, error = ?e, "DeviceName read failed");
None
}
};
(kind, name)
}
pub(super) async fn probe_features(
channel: &Arc<HidppChannel>,
slot: u8,
) -> (ProbedFeatures, Option<BatteryProbe>) {
let mut device = match Device::new(Arc::clone(channel), slot).await {
Ok(d) => d,
Err(e) => {
debug!(slot, error = ?e, "Device::new failed");
return (ProbedFeatures::default(), None);
}
};
let mut battery_probe = None;
let mut probe_haptic_controls = false;
let mut capabilities = match device.enumerate_features().await {
Ok(Some(features)) => {
let ids: Vec<u16> = features.iter().map(|f| f.id).collect();
battery_probe = battery_feature_index(ids.iter().copied());
probe_haptic_controls = ids.contains(&0x19b0) || ids.contains(&0x19c0);
Some(Capabilities::from_feature_ids(&ids))
}
Ok(None) => None,
Err(e) => {
debug!(slot, error = ?e, "enumerate_features failed");
return (ProbedFeatures::default(), None);
}
};
if let Some(caps) = capabilities.as_mut() {
if let Some(feature) = device.get_feature::<HiResWheelFeature>() {
caps.scroll_inversion = feature
.get_wheel_capabilities()
.await
.is_ok_and(|wheel| wheel.has_invert);
}
if !caps.thumbwheel
&& let Some(feature) = device.get_feature::<Gestures2Feature>()
{
caps.thumbwheel = feature.has_thumbwheel().await.unwrap_or(false);
}
if probe_haptic_controls
&& let Some(feature) = device.get_feature::<ReprogControlsFeature>()
{
caps.haptic_panel = has_haptic_panel(&feature).await;
}
}
let battery = match battery_probe {
Some(probe) => read_battery(channel, slot, probe).await,
None => None,
};
let mut identity_incomplete = false;
let model_info = match device.get_feature::<DeviceInformationFeature>() {
Some(feature) => match feature.get_device_info().await {
Ok(info) => {
let serial_number = if info.capabilities.serial_number {
match feature.get_serial_number().await {
Ok(serial) => normalize_serial_number(&serial),
Err(e) => {
debug!(slot, error = ?e, "DeviceInformation serial read failed");
identity_incomplete = true;
None
}
}
} else {
None
};
Some(DeviceModelInfo {
entity_count: info.entity_count,
serial_number,
unit_id: info.unit_id,
transports: DeviceTransports {
usb: info.transport.usb,
equad: info.transport.e_quad,
btle: info.transport.btle,
bluetooth: info.transport.bluetooth,
},
model_ids: info.model_id,
extended_model_id: info.extended_model_id,
})
}
Err(e) => {
debug!(slot, error = ?e, "DeviceInformation read failed");
identity_incomplete = true;
None
}
},
None => None,
};
let (kind, marketing_name) = read_marketing_identity(&device, slot).await;
(
ProbedFeatures {
battery,
model_info,
kind,
marketing_name,
capabilities,
identity_incomplete,
},
battery_probe,
)
}
async fn has_haptic_panel(feature: &ReprogControlsFeature) -> bool {
let Ok(count) = feature.get_count().await else {
return false;
};
for index in 0..count {
let Ok(info) = feature.get_cid_info(index).await else {
return false;
};
if info.cid == control_ids::HAPTIC_PANEL {
return info.flags.is_divertable();
}
}
false
}
#[cfg(test)]
mod tests {
use hidpp::feature::{
CreatableFeature as _, battery_status::BatteryStatusFeature,
battery_voltage::BatteryVoltageFeature, unified_battery::UnifiedBatteryFeature,
};
use super::{BatteryProbe, battery_feature_index};
#[test]
fn battery_index_is_one_based_in_the_enumerated_table() {
let table = [0x0001, UnifiedBatteryFeature::ID, 0x2201];
assert_eq!(battery_feature_index(table), Some(BatteryProbe::Unified(2)));
assert_eq!(
battery_feature_index([UnifiedBatteryFeature::ID]),
Some(BatteryProbe::Unified(1)),
"first entry maps to index 1, not 0"
);
}
#[test]
fn legacy_battery_is_found_when_unified_is_absent() {
let table = [0x0001, BatteryStatusFeature::ID, 0x2201];
assert_eq!(battery_feature_index(table), Some(BatteryProbe::Legacy(2)));
}
#[test]
fn unified_battery_is_preferred_over_legacy() {
let table = [BatteryStatusFeature::ID, 0x0001, UnifiedBatteryFeature::ID];
assert_eq!(battery_feature_index(table), Some(BatteryProbe::Unified(3)));
}
#[test]
fn voltage_battery_is_found_when_it_is_the_only_source() {
let table = [0x0001, BatteryVoltageFeature::ID, 0x2201];
assert_eq!(battery_feature_index(table), Some(BatteryProbe::Voltage(2)));
}
#[test]
fn direct_percentage_sources_outrank_the_voltage_estimate() {
let table = [BatteryVoltageFeature::ID, BatteryStatusFeature::ID];
assert_eq!(battery_feature_index(table), Some(BatteryProbe::Legacy(2)));
let table = [BatteryVoltageFeature::ID, UnifiedBatteryFeature::ID];
assert_eq!(battery_feature_index(table), Some(BatteryProbe::Unified(2)));
}
#[test]
fn no_battery_feature_means_no_index() {
assert_eq!(battery_feature_index([0x0001, 0x2201, 0x1b04]), None);
assert_eq!(battery_feature_index([]), None);
}
}