feat: enhance device normalization and classification
- Introduced a new mechanism in EventNormalizer to suppress duplicate events from child nodes of the same physical device. - Removed the device classification logic from the normalizer and replaced it with a rule-based system using Lua scripts. - Added support for user-defined device rules in Lua, allowing for flexible device naming based on various conditions. - Updated the state engine to handle device rules and resolve device names before dispatching events. - Modified the installation script to set up default configuration files for the daemon and Lua modules. - Improved the handling of systemd user services to dynamically set the ExecStart path based on the installation directory.
This commit is contained in:
parent
acbf8e1b1b
commit
d44ece3649
12 changed files with 719 additions and 476 deletions
|
|
@ -4,14 +4,16 @@ use std::sync::RwLock;
|
|||
use bread_shared::{AdapterSource, BreadEvent, RawEvent};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::core::types::DeviceClass;
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
const EVICT_MULTIPLIER: u64 = 60;
|
||||
|
||||
pub struct EventNormalizer {
|
||||
dedup_window_ms: u64,
|
||||
recent: RwLock<HashMap<String, u64>>,
|
||||
/// Tracks the first time a physical device (keyed by verb+vendor_id+product_id)
|
||||
/// fired within the current window, so subsequent child-node events from the
|
||||
/// same plug-in are suppressed at the normalizer level.
|
||||
seen_devices: RwLock<HashMap<String, u64>>,
|
||||
}
|
||||
|
||||
impl EventNormalizer {
|
||||
|
|
@ -19,6 +21,7 @@ impl EventNormalizer {
|
|||
Self {
|
||||
dedup_window_ms,
|
||||
recent: RwLock::new(HashMap::new()),
|
||||
seen_devices: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,40 +45,75 @@ impl EventNormalizer {
|
|||
|
||||
fn normalize_udev(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let action = raw.payload.get("action").and_then(Value::as_str).unwrap_or("change");
|
||||
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let class = classify_device(&raw.payload);
|
||||
let class_str = serde_json::to_string(&class)
|
||||
.unwrap_or_else(|_| "\"unknown\"".to_string())
|
||||
.replace('"', "");
|
||||
|
||||
// "bind" is the kernel attaching a driver to an interface — not a meaningful
|
||||
// device state change for automation purposes.
|
||||
if action == "bind" {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let name = raw.payload.get("name").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let vendor = raw.payload.get("id_vendor").and_then(Value::as_str).unwrap_or_default();
|
||||
let vendor_id = raw.payload.get("vendor_id").and_then(Value::as_str).unwrap_or_default();
|
||||
let product_id = raw.payload.get("product_id").and_then(Value::as_str).unwrap_or_default();
|
||||
let subsystem = raw.payload.get("subsystem").and_then(Value::as_str).unwrap_or_default();
|
||||
|
||||
// Drop anonymous child USB interfaces (e.g. 3-5:1.0, 3-5:1.1) that carry
|
||||
// no identity information — they are USB protocol artefacts, not devices.
|
||||
if name == "unknown" && vendor.is_empty() && vendor_id.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
// For connected/disconnected, suppress duplicate events from child nodes of
|
||||
// the same physical device (e.g. input66, mouse0, event17 all from one plug-in).
|
||||
// Key by verb+vendor_id+product_id so a second distinct device of the same
|
||||
// model plugged in after the window still fires correctly.
|
||||
let verb = match action {
|
||||
"add" => "connected",
|
||||
"remove" => "disconnected",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
let mut events = vec![BreadEvent {
|
||||
if (verb == "connected" || verb == "disconnected") && !vendor_id.is_empty() && !product_id.is_empty() {
|
||||
let device_key = format!("{}:{}:{}", verb, vendor_id, product_id);
|
||||
let now = raw.timestamp;
|
||||
let already_seen = {
|
||||
let seen = self.seen_devices.read().unwrap_or_else(|p| p.into_inner());
|
||||
seen.get(&device_key)
|
||||
.map(|&last| now.saturating_sub(last) < self.dedup_window_ms)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if already_seen {
|
||||
return vec![];
|
||||
}
|
||||
let mut seen = self.seen_devices.write().unwrap_or_else(|p| p.into_inner());
|
||||
seen.insert(device_key, now);
|
||||
// Evict stale entries
|
||||
let evict_before = now.saturating_sub(self.dedup_window_ms.saturating_mul(EVICT_MULTIPLIER));
|
||||
if evict_before > 0 {
|
||||
seen.retain(|_, &mut last| last >= evict_before);
|
||||
}
|
||||
}
|
||||
|
||||
let id = raw.payload.get("id").and_then(Value::as_str).unwrap_or("unknown");
|
||||
|
||||
// Device name is always "unknown" here; the state engine applies user-defined
|
||||
// classification rules from devices.lua before dispatching to subscribers.
|
||||
vec![BreadEvent {
|
||||
event: format!("bread.device.{}", verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"class": class,
|
||||
"device": "unknown",
|
||||
"name": name,
|
||||
"vendor": vendor,
|
||||
"vendor_id": vendor_id,
|
||||
"product_id": product_id,
|
||||
"subsystem": subsystem,
|
||||
"raw": raw.payload,
|
||||
}),
|
||||
}];
|
||||
|
||||
events.push(BreadEvent {
|
||||
event: format!("bread.device.{}.{}", class_str, verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"class": class,
|
||||
}),
|
||||
});
|
||||
|
||||
events
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
|
|
@ -109,13 +147,13 @@ impl EventNormalizer {
|
|||
event: "bread.monitor.connected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
data: json!({ "name": data }),
|
||||
}],
|
||||
"monitorremoved" => vec![BreadEvent {
|
||||
event: "bread.monitor.disconnected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
data: json!({ "name": data }),
|
||||
}],
|
||||
"activewindow" => vec![BreadEvent {
|
||||
event: "bread.window.focus.changed".to_string(),
|
||||
|
|
@ -288,107 +326,3 @@ fn split_hyprland_fields(data: &str) -> Vec<&str> {
|
|||
data.split(">>").collect()
|
||||
}
|
||||
|
||||
fn classify_device(payload: &Value) -> DeviceClass {
|
||||
let subsystem = payload
|
||||
.get("subsystem")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
// --- Property-based classification (reliable, hardware-agnostic) ---
|
||||
|
||||
// udev sets ID_INPUT_KEYBOARD=1 for anything that presents as a keyboard HID device.
|
||||
if payload.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return DeviceClass::Keyboard;
|
||||
}
|
||||
|
||||
// ID_INPUT_MOUSE=1 covers mice and trackballs.
|
||||
if payload.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return DeviceClass::Mouse;
|
||||
}
|
||||
|
||||
// ID_INPUT_TABLET=1 covers drawing tablets (Wacom etc).
|
||||
if payload.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return DeviceClass::Tablet;
|
||||
}
|
||||
|
||||
// USB class 0x09 = Hub. Docks expose a hub interface; they also typically
|
||||
// expose video (0x0e), audio (0x01), and ethernet (CDC 0x02) interfaces.
|
||||
// We check for hub + at least one of those secondary interfaces.
|
||||
if let Some(ifaces) = payload.get("id_usb_interfaces").and_then(Value::as_str) {
|
||||
let ifaces_lc = ifaces.to_lowercase();
|
||||
let has_hub = ifaces_lc.contains(":0900") || ifaces_lc.contains(":0902");
|
||||
let has_secondary = ifaces_lc.contains(":0e") // video
|
||||
|| ifaces_lc.contains(":0200") // CDC ethernet
|
||||
|| ifaces_lc.contains(":0100") // audio
|
||||
|| ifaces_lc.contains(":0801"); // mass storage
|
||||
if has_hub && has_secondary {
|
||||
return DeviceClass::Dock;
|
||||
}
|
||||
}
|
||||
|
||||
// USB class 0x01 = Audio.
|
||||
if let Some(cls) = payload.get("id_usb_class").and_then(Value::as_str) {
|
||||
if cls == "01" || cls.to_lowercase() == "0x01" {
|
||||
return DeviceClass::Audio;
|
||||
}
|
||||
// USB class 0x08 = Mass Storage.
|
||||
if cls == "08" || cls.to_lowercase() == "0x08" {
|
||||
return DeviceClass::Storage;
|
||||
}
|
||||
}
|
||||
|
||||
// DRM subsystem = display connector.
|
||||
if subsystem == "drm" {
|
||||
return DeviceClass::Display;
|
||||
}
|
||||
|
||||
// Block devices = storage.
|
||||
if subsystem == "block" {
|
||||
return DeviceClass::Storage;
|
||||
}
|
||||
|
||||
// Sound subsystem = audio.
|
||||
if subsystem == "sound" {
|
||||
return DeviceClass::Audio;
|
||||
}
|
||||
|
||||
// --- Name-based fallback (catches user-registered patterns and obvious names) ---
|
||||
// This runs last so the property-based rules above always win.
|
||||
|
||||
let name = payload
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| payload.get("id_model").and_then(Value::as_str))
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
let vendor = payload
|
||||
.get("id_vendor")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
|
||||
let combined = format!("{name} {vendor}");
|
||||
|
||||
if combined.contains("dock") || combined.contains("hub") || combined.contains("thunderbolt") {
|
||||
return DeviceClass::Dock;
|
||||
}
|
||||
if combined.contains("keyboard") || combined.contains("kbd") {
|
||||
return DeviceClass::Keyboard;
|
||||
}
|
||||
if combined.contains("mouse") || combined.contains("trackball") || combined.contains("trackpoint") {
|
||||
return DeviceClass::Mouse;
|
||||
}
|
||||
if combined.contains("tablet") || combined.contains("wacom") || combined.contains("stylus") {
|
||||
return DeviceClass::Tablet;
|
||||
}
|
||||
if combined.contains("audio") || combined.contains("headset") || combined.contains("speaker") || combined.contains("dac") {
|
||||
return DeviceClass::Audio;
|
||||
}
|
||||
if combined.contains("storage") || combined.contains("drive") || combined.contains("flash") || combined.contains("disk") {
|
||||
return DeviceClass::Storage;
|
||||
}
|
||||
|
||||
DeviceClass::Unknown
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use tokio::sync::{broadcast, mpsc, watch, RwLock};
|
|||
use tracing::warn;
|
||||
|
||||
use crate::core::subscriptions::{SubscriptionId, SubscriptionTable};
|
||||
use crate::core::types::{Device, DeviceClass, InterfaceState, ModuleLoadState, RuntimeState};
|
||||
use crate::core::types::{Device, DeviceRule, InterfaceState, MatchCondition, ModuleLoadState, RuntimeState};
|
||||
use crate::lua::LuaMessage;
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -46,6 +46,7 @@ pub enum StateCommand {
|
|||
SetProfile {
|
||||
name: String,
|
||||
},
|
||||
SetDeviceRules(Vec<DeviceRule>),
|
||||
}
|
||||
|
||||
impl StateHandle {
|
||||
|
|
@ -136,6 +137,10 @@ impl StateHandle {
|
|||
let _ = self.command_tx.send(StateCommand::SetProfile { name });
|
||||
}
|
||||
|
||||
pub fn set_device_rules(&self, rules: Vec<DeviceRule>) {
|
||||
let _ = self.command_tx.send(StateCommand::SetDeviceRules(rules));
|
||||
}
|
||||
|
||||
pub fn subscription_count(&self) -> Arc<AtomicU64> {
|
||||
self.subscription_count.clone()
|
||||
}
|
||||
|
|
@ -152,6 +157,7 @@ pub async fn run_state_engine(
|
|||
) {
|
||||
let mut subscriptions = SubscriptionTable::default();
|
||||
let mut watches: HashMap<SubscriptionId, String> = HashMap::new();
|
||||
let mut device_rules: Vec<DeviceRule> = Vec::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
|
@ -164,13 +170,51 @@ pub async fn run_state_engine(
|
|||
let Some(cmd) = maybe_cmd else {
|
||||
break;
|
||||
};
|
||||
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
|
||||
if let StateCommand::SetDeviceRules(rules) = cmd {
|
||||
device_rules = rules;
|
||||
} else {
|
||||
handle_command(cmd, &state, &mut subscriptions, &mut watches, &subscription_count).await;
|
||||
}
|
||||
}
|
||||
maybe_event = event_rx.recv() => {
|
||||
let Some(event) = maybe_event else {
|
||||
let Some(mut event) = maybe_event else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Resolve device name from user rules and patch the event data before
|
||||
// any subscriber sees it, then emit the named companion event.
|
||||
let device_event = if event.event == "bread.device.connected"
|
||||
|| event.event == "bread.device.disconnected"
|
||||
{
|
||||
let is_disconnect = event.event == "bread.device.disconnected";
|
||||
let id = event.data.get("id").and_then(Value::as_str).unwrap_or("unknown").to_string();
|
||||
|
||||
// On disconnect, udev strips vendor/product identifiers from the event.
|
||||
// Look up the device by id in the current state (it's still present
|
||||
// because apply_event_to_state hasn't run yet) and reuse the stored name.
|
||||
let device = if is_disconnect {
|
||||
state.read().await
|
||||
.devices.connected.iter()
|
||||
.find(|d| d.id == id)
|
||||
.map(|d| d.device.clone())
|
||||
.unwrap_or_else(|| resolve_device(&device_rules, &event.data))
|
||||
} else {
|
||||
resolve_device(&device_rules, &event.data)
|
||||
};
|
||||
|
||||
if let Some(data) = event.data.as_object_mut() {
|
||||
data.insert("device".to_string(), Value::String(device.clone()));
|
||||
}
|
||||
let verb = if is_disconnect { "disconnected" } else { "connected" };
|
||||
Some(BreadEvent::new(
|
||||
format!("bread.device.{}.{}", device, verb),
|
||||
AdapterSource::Udev,
|
||||
json!({ "id": id, "device": device }),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (before_snapshot, after_snapshot) = if watches.is_empty() {
|
||||
(None, None)
|
||||
} else {
|
||||
|
|
@ -188,6 +232,13 @@ pub async fn run_state_engine(
|
|||
|
||||
dispatch_event(&event, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
||||
|
||||
if let Some(dev_ev) = device_event {
|
||||
let mut guard = state.write().await;
|
||||
apply_event_to_state(&mut guard, &dev_ev);
|
||||
drop(guard);
|
||||
dispatch_event(&dev_ev, &mut subscriptions, &lua_tx, &event_stream_tx, &subscription_count);
|
||||
}
|
||||
|
||||
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
||||
for (_id, path) in watches.iter() {
|
||||
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
||||
|
|
@ -273,6 +324,9 @@ async fn handle_command(
|
|||
guard.profile.active = name;
|
||||
}
|
||||
}
|
||||
StateCommand::SetDeviceRules(_) => {
|
||||
// Handled directly in run_state_engine before this function is called.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +453,95 @@ fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_device(rules: &[DeviceRule], data: &Value) -> String {
|
||||
for rule in rules {
|
||||
if !rule.conditions.is_empty() && rule.conditions.iter().all(|c| condition_matches(c, data)) {
|
||||
return rule.device.clone();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
fn condition_matches(cond: &MatchCondition, data: &Value) -> bool {
|
||||
if let Some(ref expected) = cond.vendor_id {
|
||||
let actual = data.get("vendor_id").and_then(Value::as_str).unwrap_or("");
|
||||
if actual.to_lowercase() != expected.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref expected) = cond.product_id {
|
||||
let actual = data.get("product_id").and_then(Value::as_str).unwrap_or("");
|
||||
if actual.to_lowercase() != expected.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref expected) = cond.name {
|
||||
let actual = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||
if actual != expected.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref expected) = cond.vendor {
|
||||
let actual = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||
if actual != expected.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref contains) = cond.name_contains {
|
||||
let name = data.get("name").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||
let vendor = data.get("vendor").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||
let combined = format!("{name} {vendor}");
|
||||
if !combined.contains(contains.to_lowercase().as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(expected) = cond.id_input_keyboard {
|
||||
if data.get("id_input_keyboard").and_then(Value::as_bool).unwrap_or(false) != expected {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(expected) = cond.id_input_mouse {
|
||||
if data.get("id_input_mouse").and_then(Value::as_bool).unwrap_or(false) != expected {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(expected) = cond.id_input_tablet {
|
||||
if data.get("id_input_tablet").and_then(Value::as_bool).unwrap_or(false) != expected {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if cond.usb_hub == Some(true) {
|
||||
let ifaces = data
|
||||
.get("id_usb_interfaces")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let has_hub = ifaces.contains(":0900") || ifaces.contains(":0902");
|
||||
let has_secondary = ifaces.contains(":0e")
|
||||
|| ifaces.contains(":0200")
|
||||
|| ifaces.contains(":0100")
|
||||
|| ifaces.contains(":0801");
|
||||
if !(has_hub && has_secondary) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref expected) = cond.id_usb_class {
|
||||
let actual = data.get("id_usb_class").and_then(Value::as_str).unwrap_or("");
|
||||
if actual.to_lowercase() != expected.to_lowercase()
|
||||
&& actual.to_lowercase() != format!("0x{}", expected.to_lowercase())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref expected) = cond.subsystem {
|
||||
let actual = data.get("subsystem").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||
if actual != expected.to_lowercase() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool) {
|
||||
let id = data
|
||||
.get("id")
|
||||
|
|
@ -411,10 +554,11 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
|
|||
return;
|
||||
}
|
||||
|
||||
let class = data
|
||||
.get("class")
|
||||
.and_then(|v| serde_json::from_value::<DeviceClass>(v.clone()).ok())
|
||||
.unwrap_or(DeviceClass::Unknown);
|
||||
let device = data
|
||||
.get("device")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
state.devices.connected.push(Device {
|
||||
id,
|
||||
|
|
@ -423,7 +567,7 @@ fn apply_device_change(state: &mut RuntimeState, data: &Value, connected: bool)
|
|||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
class,
|
||||
device,
|
||||
subsystem: data
|
||||
.get("subsystem")
|
||||
.and_then(Value::as_str)
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ pub struct DeviceTopology {
|
|||
pub struct Device {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub class: DeviceClass,
|
||||
pub device: String,
|
||||
pub subsystem: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vendor_id: Option<String>,
|
||||
|
|
@ -63,17 +63,30 @@ pub struct Device {
|
|||
pub product_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeviceClass {
|
||||
Dock,
|
||||
Keyboard,
|
||||
Mouse,
|
||||
Tablet,
|
||||
Display,
|
||||
Storage,
|
||||
Audio,
|
||||
Unknown,
|
||||
/// One set of match conditions. All provided fields must match.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MatchCondition {
|
||||
pub vendor_id: Option<String>,
|
||||
pub product_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub name_contains: Option<String>,
|
||||
pub id_input_keyboard: Option<bool>,
|
||||
pub id_input_mouse: Option<bool>,
|
||||
pub id_input_tablet: Option<bool>,
|
||||
/// True triggers the compound USB hub + secondary-interface check.
|
||||
pub usb_hub: Option<bool>,
|
||||
pub id_usb_class: Option<String>,
|
||||
pub subsystem: Option<String>,
|
||||
}
|
||||
|
||||
/// A device rule from `devices.lua`. The device name is assigned if ANY
|
||||
/// condition in `conditions` matches (OR semantics across conditions,
|
||||
/// AND semantics within a condition).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceRule {
|
||||
pub device: String,
|
||||
pub conditions: Vec<MatchCondition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue