use serde::{Deserialize, Serialize};
use crate::hid::DeviceRoute;
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) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"mouse" => Some(Self::Mouse),
"keyboard" => Some(Self::Keyboard),
"numpad" => Some(Self::Numpad),
"presenter" => Some(Self::Presenter),
"remote" | "remotecontrol" => Some(Self::Remote),
"trackball" => Some(Self::Trackball),
"touchpad" | "trackpad" => Some(Self::Touchpad),
"tablet" => Some(Self::Tablet),
"gamepad" => Some(Self::Gamepad),
"joystick" => Some(Self::Joystick),
"headset" => Some(Self::Headset),
"camera" => Some(Self::Camera),
"light" | "lighting" | "illumination_light" => Some(Self::Light),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[expect(
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,
#[serde(default)]
pub haptic_feedback: bool,
#[serde(default)]
pub haptic_panel: bool,
#[serde(default)]
pub dpi_gestures: bool,
#[serde(default)]
pub fn_lock: bool,
#[serde(default)]
pub os_layout: bool,
#[serde(default)]
pub report_rate: bool,
#[serde(default)]
pub backlight: bool,
#[serde(default)]
pub easy_switch: 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; 4] = [0x8070, 0x8071, 0x8081, 0x8080];
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),
haptic_feedback: ids.contains(&0x19b0),
haptic_panel: false,
dpi_gestures: false,
fn_lock: ids.contains(&0x40a0) || ids.contains(&0x40a2) || ids.contains(&0x40a3),
os_layout: ids.contains(&0x4531),
report_rate: ids.contains(&0x8060) || ids.contains(&0x8061),
backlight: ids.contains(&0x1982),
easy_switch: ids.contains(&0x1815),
}
}
#[must_use]
pub fn presumed_from_kind(kind: DeviceKind) -> Self {
match kind {
DeviceKind::Mouse | DeviceKind::Trackball => Self {
buttons: true,
pointer: true,
..Self::default()
},
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: Option<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)]
#[serde(deny_unknown_fields)]
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 model_key(&self) -> String {
format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
}
#[must_use]
pub fn transport_for_product_id(&self, product_id: u16) -> Option<ModelTransport> {
let t = self.transports;
[
(t.bluetooth, ModelTransport::Bluetooth),
(t.btle, ModelTransport::Btle),
(t.equad, ModelTransport::Equad),
(t.usb, ModelTransport::Usb),
]
.into_iter()
.filter_map(|(enabled, kind)| enabled.then_some(kind))
.zip(self.model_ids)
.find(|(_, pid)| *pid == product_id)
.map(|(kind, _)| kind)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelTransport {
Bluetooth,
Btle,
Equad,
Usb,
}
#[expect(
clippy::struct_excessive_bools,
reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
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>,
#[serde(default)]
pub settings_revision: u64,
}
#[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)]
pub registry_model_id: Option<String>,
}
impl StandaloneDevice {
#[must_use]
pub fn route(&self) -> DeviceRoute {
DeviceRoute::from(&self.address)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceInventory {
pub receiver: ReceiverInfo,
pub paired: Vec<PairedDevice>,
}
#[cfg(test)]
mod tests {
use super::{
BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
DeviceModelInfo, DeviceTransports, LightValueRange, LightValueUnit, ModelTransport,
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: Some(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,
haptic_feedback: false,
haptic_panel: false,
dpi_gestures: false,
fn_lock: false,
os_layout: false,
report_rate: false,
backlight: false,
easy_switch: false,
}),
settings_revision: 0,
}],
}
}
#[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 transport_for_product_id_resolves_the_live_slot() {
let model = DeviceModelInfo {
entity_count: 3,
serial_number: None,
unit_id: [0, 0, 0, 0],
transports: DeviceTransports {
usb: true,
equad: true,
btle: true,
bluetooth: false,
},
model_ids: [0xb38a, 0x40b5, 0xc356],
extended_model_id: 0x01,
};
assert_eq!(
model.transport_for_product_id(0xc356),
Some(ModelTransport::Usb),
"the USB-slot pid must resolve to Usb even though the device \
also supports BTLE"
);
assert_eq!(
model.transport_for_product_id(0xb38a),
Some(ModelTransport::Btle)
);
assert_eq!(
model.transport_for_product_id(0x40b5),
Some(ModelTransport::Equad)
);
assert_eq!(
model.transport_for_product_id(0xdead),
None,
"an unrecognized pid must not silently match a slot"
);
}
#[test]
fn registry_type_is_case_folded() {
assert_eq!(
DeviceKind::from_registry_type("mouse"),
Some(DeviceKind::Mouse)
);
assert_eq!(
DeviceKind::from_registry_type("MOUSE"),
Some(DeviceKind::Mouse)
);
assert_eq!(
DeviceKind::from_registry_type(" Keyboard "),
Some(DeviceKind::Keyboard)
);
}
#[test]
fn unknown_registry_type_defers_to_the_caller() {
assert_eq!(DeviceKind::from_registry_type("webcam"), None);
assert_eq!(DeviceKind::from_registry_type(""), None);
}
#[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,
haptic_feedback: false,
haptic_panel: false,
dpi_gestures: false,
fn_lock: false,
os_layout: false,
report_rate: false,
backlight: false,
easy_switch: false,
}
);
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,
haptic_feedback: false,
haptic_panel: false,
dpi_gestures: false,
fn_lock: false,
os_layout: false,
report_rate: false,
backlight: false,
easy_switch: false,
}
);
assert_eq!(
Capabilities::from_feature_ids(&[0x0000, 0x0003]),
Capabilities::default()
);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x4531]).os_layout);
assert!(!Capabilities::from_feature_ids(&[0x0001, 0x40a3]).os_layout);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x8060]).report_rate);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x8061]).report_rate);
assert!(!Capabilities::from_feature_ids(&[0x0001, 0x2202]).report_rate);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x1982]).backlight);
assert!(!Capabilities::from_feature_ids(&[0x0001, 0x1981]).backlight);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x1815]).easy_switch);
assert!(!Capabilities::from_feature_ids(&[0x0001, 0x1814]).easy_switch);
}
#[test]
fn every_drivable_lighting_family_earns_the_tab() {
for id in [0x8070, 0x8071, 0x8080, 0x8081] {
assert!(
Capabilities::from_feature_ids(&[0x0001, id]).lighting,
"0x{id:04x} must offer the lighting tab"
);
}
assert!(!Capabilities::from_feature_ids(&[0x0001, 0x1982]).lighting);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x40a3]).fn_lock);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x40a2]).fn_lock);
assert!(Capabilities::from_feature_ids(&[0x0001, 0x40a0]).fn_lock);
assert!(!Capabilities::from_feature_ids(&[0x0001, 0x1982]).fn_lock);
}
#[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() {
LightValueRange::new(10, 1, 1, LightValueUnit::Lumens)
.expect_err("a minimum above the maximum must be rejected");
LightValueRange::new(0, 10, 0, LightValueUnit::Lumens)
.expect_err("a zero step must be rejected");
LightValueRange::new(0, 10, 3, LightValueUnit::Lumens)
.expect_err("a step that does not divide the span must be rejected");
LightValueRange::new(0, 101, 1, LightValueUnit::Percent)
.expect_err("a percent range above 100 must be rejected");
}
#[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",
);
result.expect_err("a zero step must not survive deserialization");
}
}