use serde::{Deserialize, Serialize};
mod light;
pub use light::{LightCapabilities, LightValueRange, LightValueRangeError, LightValueUnit};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeviceKind {
Mouse,
Keyboard,
Numpad,
Presenter,
Remote,
Trackball,
Touchpad,
Tablet,
Gamepad,
Joystick,
Headset,
Camera,
Unknown,
Light,
}
impl DeviceKind {
#[must_use]
pub fn from_registry_type(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"mouse" => Self::Mouse,
"keyboard" => Self::Keyboard,
"numpad" => Self::Numpad,
"presenter" => Self::Presenter,
"remote" | "remotecontrol" => Self::Remote,
"trackball" => Self::Trackball,
"touchpad" | "trackpad" => Self::Touchpad,
"tablet" => Self::Tablet,
"gamepad" => Self::Gamepad,
"joystick" => Self::Joystick,
"headset" => Self::Headset,
"camera" => Self::Camera,
"light" | "lighting" | "illumination_light" => Self::Light,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[allow(
clippy::struct_excessive_bools,
reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit"
)]
pub struct Capabilities {
pub buttons: bool,
pub pointer: bool,
pub lighting: bool,
pub scroll_inversion: bool,
#[serde(default)]
pub hires_wheel: bool,
#[serde(default)]
pub thumbwheel: bool,
}
impl Capabilities {
#[must_use]
pub fn from_feature_ids(ids: &[u16]) -> Self {
const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
const POINTER: [u16; 2] = [0x2201, 0x2202];
const LIGHTING: [u16; 2] = [0x8080, 0x8070];
let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
Self {
buttons: has(&BUTTONS),
pointer: has(&POINTER),
lighting: has(&LIGHTING),
scroll_inversion: false,
hires_wheel: ids.contains(&0x2121),
thumbwheel: ids.contains(&0x2150),
}
}
#[must_use]
pub fn presumed_from_kind(kind: DeviceKind) -> Self {
match kind {
DeviceKind::Mouse | DeviceKind::Trackball => Self {
buttons: true,
pointer: true,
lighting: false,
scroll_inversion: false,
hires_wheel: false,
thumbwheel: false,
},
DeviceKind::Keyboard => Self {
lighting: true,
..Self::default()
},
_ => Self::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BatteryLevel {
Critical,
Low,
Good,
Full,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BatteryStatus {
Discharging,
Charging,
ChargingSlow,
Full,
Error,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BatteryInfo {
pub percentage: u8,
pub level: BatteryLevel,
pub status: BatteryStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReceiverInfo {
pub name: String,
pub vendor_id: u16,
pub product_id: u16,
pub unique_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceModelInfo {
pub entity_count: u8,
pub serial_number: Option<String>,
pub unit_id: [u8; 4],
pub transports: DeviceTransports,
pub model_ids: [u16; 3],
pub extended_model_id: u8,
}
impl DeviceModelInfo {
#[must_use]
pub fn config_key(&self) -> String {
format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
}
}
#[allow(
clippy::struct_excessive_bools,
reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceTransports {
pub usb: bool,
pub equad: bool,
pub btle: bool,
pub bluetooth: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairedDevice {
pub slot: u8,
pub codename: Option<String>,
pub wpid: Option<u16>,
pub kind: DeviceKind,
pub online: bool,
pub battery: Option<BatteryInfo>,
pub model_info: Option<DeviceModelInfo>,
pub capabilities: Option<Capabilities>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RawDeviceAddress {
pub vendor_id: u16,
pub product_id: u16,
pub usage_page: u16,
pub usage_id: u16,
pub identity: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandaloneDevice {
pub address: RawDeviceAddress,
pub display_name: String,
pub manufacturer: Option<String>,
pub serial_number: Option<String>,
pub unit_id: [u8; 4],
pub kind: DeviceKind,
pub online: bool,
pub capabilities: Option<Capabilities>,
pub light_capabilities: Option<LightCapabilities>,
pub driver_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_model_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceInventory {
pub receiver: ReceiverInfo,
pub paired: Vec<PairedDevice>,
}
#[cfg(test)]
mod tests {
#![allow(
clippy::expect_used,
reason = "range fixture construction is intentionally asserted in tests"
)]
use super::{
BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
DeviceModelInfo, DeviceTransports, LightValueRange, LightValueUnit, PairedDevice,
ReceiverInfo,
};
fn inventory(slot: u8, wpid: Option<u16>, battery_percentage: u8) -> DeviceInventory {
DeviceInventory {
receiver: ReceiverInfo {
name: "Logi Bolt Receiver".to_string(),
vendor_id: 0x046d,
product_id: 0xc548,
unique_id: Some("receiver-1".to_string()),
},
paired: vec![PairedDevice {
slot,
codename: Some("MX Test".to_string()),
wpid,
kind: DeviceKind::Mouse,
online: true,
battery: Some(BatteryInfo {
percentage: battery_percentage,
level: BatteryLevel::Good,
status: BatteryStatus::Discharging,
}),
model_info: Some(DeviceModelInfo {
entity_count: 1,
serial_number: Some("serial-1".to_string()),
unit_id: [1, 2, 3, 4],
transports: DeviceTransports {
usb: true,
equad: true,
btle: false,
bluetooth: false,
},
model_ids: [0xb023, 0, 0],
extended_model_id: 0x02,
}),
capabilities: Some(Capabilities {
buttons: true,
pointer: true,
lighting: false,
scroll_inversion: false,
hires_wheel: false,
thumbwheel: false,
}),
}],
}
}
#[test]
fn device_inventory_equality_includes_nested_device_fields() {
let base = inventory(1, Some(0xb023), 86);
assert_eq!(base, base.clone());
assert_ne!(
base,
inventory(2, Some(0xb023), 86),
"slot changes must affect inventory equality"
);
assert_ne!(
base,
inventory(1, Some(0xb024), 86),
"wireless product id changes must affect inventory equality"
);
assert_ne!(
base,
inventory(1, Some(0xb023), 87),
"nested battery changes must affect inventory equality"
);
}
#[test]
fn registry_type_is_case_folded() {
assert_eq!(DeviceKind::from_registry_type("mouse"), DeviceKind::Mouse);
assert_eq!(DeviceKind::from_registry_type("MOUSE"), DeviceKind::Mouse);
assert_eq!(
DeviceKind::from_registry_type(" Keyboard "),
DeviceKind::Keyboard
);
}
#[test]
fn unknown_registry_type_defers_to_the_caller() {
assert_eq!(
DeviceKind::from_registry_type("webcam"),
DeviceKind::Unknown
);
assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown);
}
#[test]
fn capabilities_track_the_driving_feature_ids() {
use super::Capabilities;
let mouse =
Capabilities::from_feature_ids(&[0x0003, 0x1b04, 0x2121, 0x2150, 0x2202, 0x2110]);
assert_eq!(
mouse,
Capabilities {
buttons: true,
pointer: true,
lighting: false,
scroll_inversion: false,
hires_wheel: true,
thumbwheel: true,
}
);
assert!(!Capabilities::from_feature_ids(&[0x0003, 0x1b04]).thumbwheel);
let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
assert_eq!(
keyboard,
Capabilities {
buttons: false,
pointer: false,
lighting: true,
scroll_inversion: false,
hires_wheel: false,
thumbwheel: false,
}
);
assert_eq!(
Capabilities::from_feature_ids(&[0x0000, 0x0003]),
Capabilities::default()
);
}
#[test]
fn persisted_capabilities_without_appended_wheel_fields_load_as_unsupported()
-> Result<(), toml::de::Error> {
use super::Capabilities;
let capabilities: Capabilities = toml::from_str(
r"
buttons = true
pointer = true
lighting = false
scroll_inversion = true
",
)?;
assert!(!capabilities.hires_wheel);
assert!(!capabilities.thumbwheel);
assert!(capabilities.scroll_inversion);
Ok(())
}
#[test]
fn presumed_capabilities_keep_an_unprobed_mouse_configurable() {
use super::Capabilities;
let mouse = Capabilities::presumed_from_kind(DeviceKind::Mouse);
assert!(mouse.buttons && mouse.pointer && !mouse.lighting);
assert!(!mouse.thumbwheel);
assert!(Capabilities::presumed_from_kind(DeviceKind::Keyboard).lighting);
assert_eq!(
Capabilities::presumed_from_kind(DeviceKind::Unknown),
Capabilities::default()
);
}
#[test]
fn light_ranges_reject_invalid_grids_and_units() {
assert!(LightValueRange::new(10, 1, 1, LightValueUnit::Lumens).is_err());
assert!(LightValueRange::new(0, 10, 0, LightValueUnit::Lumens).is_err());
assert!(LightValueRange::new(0, 10, 3, LightValueUnit::Lumens).is_err());
assert!(LightValueRange::new(0, 101, 1, LightValueUnit::Percent).is_err());
}
#[test]
fn light_ranges_quantize_without_leaving_the_advertised_grid() {
let range = LightValueRange::new(20, 250, 10, LightValueUnit::Lumens).expect("valid range");
assert_eq!(range.native_for_percent(0), Some(20));
assert_eq!(range.native_for_percent(50), Some(140));
assert_eq!(range.native_for_percent(100), Some(250));
assert_eq!(range.quantize(249), 250);
assert!(range.contains(range.native_for_percent(65).expect("mapped value")));
}
#[test]
fn invalid_light_ranges_fail_toml_deserialization() {
let result = toml::from_str::<LightValueRange>(
"min = 2700\nmax = 6500\nstep = 0\nunit = 'kelvin'\n",
);
assert!(result.is_err());
}
}