diff --git a/profiles/pci/graphic_drivers/profiles.toml b/profiles/pci/graphic_drivers/profiles.toml index 73edd26..fb5e826 100644 --- a/profiles/pci/graphic_drivers/profiles.toml +++ b/profiles/pci/graphic_drivers/profiles.toml @@ -22,6 +22,7 @@ class_ids = "0300 0302 0380" vendor_ids = "10de" device_ids = "*" priority = 10 +driver_conflict_group = "nvidia" packages = 'nvidia-utils egl-wayland nvidia-settings opencl-nvidia lib32-opencl-nvidia lib32-nvidia-utils libva-nvidia-driver vulkan-icd-loader lib32-vulkan-icd-loader' conditional_packages = """ modules="" @@ -100,6 +101,7 @@ desc = 'Closed source NVIDIA drivers for Linux (580xx)' class_ids = "0300 0302 0380" vendor_ids = "10de" priority = 12 +driver_conflict_group = "nvidia" packages = 'nvidia-580xx-dkms nvidia-580xx-utils nvidia-580xx-settings opencl-nvidia-580xx lib32-opencl-nvidia-580xx lib32-nvidia-580xx-utils libva-nvidia-driver vulkan-icd-loader lib32-vulkan-icd-loader' device_ids = '>/var/lib/chwd/ids/nvidia-580.ids' conditional_packages = """ @@ -167,6 +169,7 @@ priority = 14 class_ids = "0300 0302 0380" vendor_ids = "10de" device_ids = '>/var/lib/chwd/ids/nvidia-470.ids' +driver_conflict_group = "nvidia" packages = 'nvidia-470xx-dkms nvidia-470xx-utils nvidia-470xx-settings opencl-nvidia-470xx vulkan-icd-loader lib32-nvidia-470xx-utils lib32-opencl-nvidia-470xx lib32-vulkan-icd-loader libva-nvidia-driver' [nvidia-dkms-470xx.prime] diff --git a/src/data.rs b/src/data.rs index f2da61a..f85a555 100644 --- a/src/data.rs +++ b/src/data.rs @@ -17,11 +17,13 @@ use crate::device::Device; use crate::profile::Profile; +use regex::Regex; +use std::collections::HashMap; use std::fs; use std::path::Path; use std::sync::Arc; use subprocess::Exec; -use regex::Regex; + pub type ListOfProfilesT = Vec; pub type ListOfDevicesT = Vec; @@ -148,9 +150,9 @@ fn fill_profiles( Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, Err(e) => { - log::warn!("failed to read profile directory '{}': {}", conf_path, e); + log::warn!("failed to read profile directory '{conf_path}': {e}"); return; - } + }, }; for entry in dir_entries { let config_file_path = format!( @@ -264,9 +266,9 @@ fn fill_usb_devices() -> ListOfDevicesT { class_name: String::new(), device_name: usb_dev.resolved_product_name(&desc, usb_ids.as_ref()), vendor_name: usb_dev.resolved_vendor_name(&desc, usb_ids.as_ref()), - class_id: from_hex(desc.bDeviceClass as u32, 2), - device_id: from_hex(desc.idProduct as u32, 4), - vendor_id: from_hex(desc.idVendor as u32, 4), + class_id: from_hex(u32::from(desc.bDeviceClass), 2), + device_id: from_hex(u32::from(desc.idProduct), 4), + vendor_id: from_hex(u32::from(desc.idVendor), 4), sysfs_busid: usb_dev.sysfs_busid(), sysfs_id: String::new(), available_profiles: vec![], @@ -467,6 +469,114 @@ fn add_profile_sorted(profiles: &mut Vec>, new_profile: &Profile) { profiles.sort_by_key(|rhs| std::cmp::Reverse(rhs.priority)); } +/// Result of conflict group resolution for a set of devices. +#[derive(Debug, Default)] +pub struct ConflictResolution { + /// Map from device `sysfs_busid` → profile name to install. + pub device_profile_map: Vec<(String, String)>, + /// Device bus IDs that were skipped due to incompatibility. + pub skipped_devices: Vec, + /// Human-readable warnings for skipped devices. + pub warnings: Vec, +} + +/// Resolves conflict groups across all devices. +/// +/// For each conflict group, finds a single profile that matches ALL devices +/// needing a profile from that group. If no single profile covers all devices, +/// picks the primary (highest-priority) device's profile and skips the rest. +#[must_use] +pub fn resolve_conflict_groups(devices: &[Device]) -> ConflictResolution { + let mut result = ConflictResolution::default(); + let groups = group_by_conflict(devices); + + for (group_name, indices) in &groups { + if indices.len() == 1 { + let device = &devices[indices[0]]; + let profile = &device.available_profiles[0]; + result.device_profile_map.push((device.sysfs_busid.clone(), profile.name.clone())); + continue; + } + + if let Some(name) = find_common_profile(devices, indices, group_name) { + for &idx in indices { + result.device_profile_map.push((devices[idx].sysfs_busid.clone(), name.clone())); + } + } else { + resolve_incompatible(&mut result, devices, indices, group_name); + } + } + + result +} + +fn group_by_conflict(devices: &[Device]) -> HashMap> { + let mut groups: HashMap> = HashMap::new(); + for (idx, device) in devices.iter().enumerate() { + if let Some(group) = + device.available_profiles.first().and_then(|p| p.driver_conflict_group.clone()) + { + groups.entry(group).or_default().push(idx); + } + } + groups +} + +/// Walks the first device's `available_profiles` (already priority-sorted desc), +/// returning the first profile that also appears in every other device's list. +fn find_common_profile(devices: &[Device], indices: &[usize], group_name: &str) -> Option { + let first = &devices[indices[0]]; + for candidate in &first.available_profiles { + if candidate.driver_conflict_group.as_deref() != Some(group_name) { + continue; + } + let all_match = indices[1..] + .iter() + .all(|&idx| devices[idx].available_profiles.iter().any(|p| p.name == candidate.name)); + if all_match { + return Some(candidate.name.clone()); + } + } + None +} + +fn resolve_incompatible( + result: &mut ConflictResolution, + devices: &[Device], + indices: &[usize], + group_name: &str, +) { + let primary_idx = indices + .iter() + .copied() + .max_by_key(|i| devices[*i].available_profiles.first().map_or(0, |p| p.priority)) + .unwrap(); + + let primary = &devices[primary_idx]; + let primary_profile = &primary.available_profiles[0]; + result.device_profile_map.push((primary.sysfs_busid.clone(), primary_profile.name.clone())); + + for &idx in indices { + if idx == primary_idx { + continue; + } + let secondary = &devices[idx]; + let secondary_profile = &secondary.available_profiles[0]; + result.skipped_devices.push(secondary.sysfs_busid.clone()); + result.warnings.push(format!( + "Skipping {} ({}): no single '{}' driver covers both this device and {} ({}). Primary \ + uses '{}', secondary requires '{}'.", + secondary.device_info(), + secondary.sysfs_busid, + group_name, + primary.device_info(), + primary.sysfs_busid, + primary_profile.name, + secondary_profile.name, + )); + } +} + #[cfg(test)] mod tests { use crate::data; @@ -916,16 +1026,18 @@ mod tests { ) -> crate::profile::Profile { crate::profile::Profile { cpu_family: cpu_family.map(str::to_string), - cpu_models: cpu_models - .map(|v| v.into_iter().map(str::to_string).collect()), + cpu_models: cpu_models.map(|v| v.into_iter().map(str::to_string).collect()), ..Default::default() } } #[test] fn cpu_filter_matches_family_and_model() { - let cpu_info = - crate::hwd_misc::CpuInfo { vendor: "GenuineIntel".into(), family: "6".into(), model: "154".into() }; + let cpu_info = crate::hwd_misc::CpuInfo { + vendor: "GenuineIntel".into(), + family: "6".into(), + model: "154".into(), + }; let profile = cpu_test_profile(Some("6"), Some(vec!["151", "154", "183"])); assert!(data::matches_cpu_filter(&profile, &cpu_info)); @@ -933,8 +1045,11 @@ mod tests { #[test] fn cpu_filter_rejects_wrong_family() { - let cpu_info = - crate::hwd_misc::CpuInfo { vendor: "AuthenticAMD".into(), family: "25".into(), model: "80".into() }; + let cpu_info = crate::hwd_misc::CpuInfo { + vendor: "AuthenticAMD".into(), + family: "25".into(), + model: "80".into(), + }; let profile = cpu_test_profile(Some("6"), Some(vec!["151", "154"])); assert!(!data::matches_cpu_filter(&profile, &cpu_info)); @@ -942,8 +1057,11 @@ mod tests { #[test] fn cpu_filter_rejects_wrong_model() { - let cpu_info = - crate::hwd_misc::CpuInfo { vendor: "GenuineIntel".into(), family: "6".into(), model: "142".into() }; + let cpu_info = crate::hwd_misc::CpuInfo { + vendor: "GenuineIntel".into(), + family: "6".into(), + model: "142".into(), + }; let profile = cpu_test_profile(Some("6"), Some(vec!["151", "154"])); assert!(!data::matches_cpu_filter(&profile, &cpu_info)); @@ -951,17 +1069,23 @@ mod tests { #[test] fn cpu_filter_family_only_matches() { - let cpu_info = - crate::hwd_misc::CpuInfo { vendor: "GenuineIntel".into(), family: "6".into(), model: "999".into() }; + let cpu_info = crate::hwd_misc::CpuInfo { + vendor: "GenuineIntel".into(), + family: "6".into(), + model: "999".into(), + }; let profile = cpu_test_profile(Some("6"), None); - // no cpu_models filter — any model in family 6 should match + // any model in family 6 should match assert!(data::matches_cpu_filter(&profile, &cpu_info)); } #[test] fn cpu_filter_no_filter_matches_all() { - let cpu_info = - crate::hwd_misc::CpuInfo { vendor: "AuthenticAMD".into(), family: "25".into(), model: "80".into() }; + let cpu_info = crate::hwd_misc::CpuInfo { + vendor: "AuthenticAMD".into(), + family: "25".into(), + model: "80".into(), + }; let profile = cpu_test_profile(None, None); // no cpu_family, no cpu_models assert!(data::matches_cpu_filter(&profile, &cpu_info)); @@ -986,4 +1110,150 @@ mod tests { vec![35, 26] ); } + + fn nvidia_profile( + name: &str, + priority: i32, + conflict_group: Option<&str>, + ) -> crate::profile::Profile { + crate::profile::Profile { + name: name.to_owned(), + priority, + packages: "nvidia-utils".to_owned(), + driver_conflict_group: conflict_group.map(str::to_string), + hwd_ids: vec![crate::profile::HardwareID { + class_ids: vec!["0300".to_owned()], + vendor_ids: vec!["10de".to_owned()], + device_ids: vec!["*".to_owned()], + ..Default::default() + }], + ..Default::default() + } + } + + fn gpu_device(bus_id: &str, profiles: Vec) -> Device { + let profiles: Vec> = + profiles.into_iter().map(std::sync::Arc::new).collect(); + Device { + class_name: "VGA compatible controller".to_string(), + device_name: "NVIDIA GPU".to_string(), + vendor_name: "NVIDIA".to_string(), + class_id: "0300".to_string(), + device_id: "1234".to_string(), + vendor_id: "10de".to_string(), + sysfs_busid: bus_id.to_string(), + sysfs_id: String::new(), + available_profiles: profiles, + installed_profiles: vec![], + } + } + + #[test] + fn conflict_resolution_single_device_uses_best_profile() { + let open_profile = nvidia_profile("nvidia-open-dkms", 10, Some("nvidia")); + let devices = vec![gpu_device("0000:01:00.0", vec![open_profile.clone()])]; + + let result = data::resolve_conflict_groups(&devices); + + assert_eq!(result.device_profile_map.len(), 1); + assert_eq!( + result.device_profile_map[0], + ("0000:01:00.0".into(), "nvidia-open-dkms".into()) + ); + assert!(result.skipped_devices.is_empty()); + assert!(result.warnings.is_empty()); + } + + #[test] + fn conflict_resolution_common_driver_found() { + // Two GPUs that both support nvidia-580xx + let open_profile = nvidia_profile("nvidia-open-dkms", 10, Some("nvidia")); + let closed_profile = nvidia_profile("nvidia-dkms-580xx", 12, Some("nvidia")); + + // GPU A: both profiles match, closed wins (higher priority) + let gpu_a = gpu_device("0000:01:00.0", vec![closed_profile.clone(), open_profile.clone()]); + // GPU B: both profiles match, closed wins + let gpu_b = gpu_device("0000:02:00.0", vec![closed_profile.clone(), open_profile.clone()]); + + let devices = vec![gpu_a, gpu_b]; + + let result = data::resolve_conflict_groups(&devices); + + // Common driver: nvidia-dkms-580xx (highest priority that matches both) + assert_eq!(result.device_profile_map.len(), 2); + assert_eq!( + result.device_profile_map[0], + ("0000:01:00.0".into(), "nvidia-dkms-580xx".into()) + ); + assert_eq!( + result.device_profile_map[1], + ("0000:02:00.0".into(), "nvidia-dkms-580xx".into()) + ); + assert!(result.skipped_devices.is_empty()); + } + + #[test] + fn conflict_resolution_incompatible_skips_secondary() { + // GPU A only matches closed (580xx), GPU B only matches open — no common driver + let closed_profile = nvidia_profile("nvidia-dkms-580xx", 12, Some("nvidia")); + let open_profile = nvidia_profile("nvidia-open-dkms", 10, Some("nvidia")); + + // GPU A: only closed matches (GTX 1080 scenario) + let gpu_a = gpu_device("0000:01:00.0", vec![closed_profile.clone()]); + // GPU B: only open matches (RTX 5080 scenario) + let gpu_b = gpu_device("0000:09:00.0", vec![open_profile.clone()]); + + let devices = vec![gpu_a, gpu_b]; + + let result = data::resolve_conflict_groups(&devices); + + // Primary (highest priority) = gpu_a with nvidia-dkms-580xx + assert_eq!(result.device_profile_map.len(), 1); + assert_eq!( + result.device_profile_map[0], + ("0000:01:00.0".into(), "nvidia-dkms-580xx".into()) + ); + assert_eq!(result.skipped_devices.len(), 1); + assert_eq!(result.skipped_devices[0], "0000:09:00.0"); + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("0000:09:00.0")); + } + + #[test] + fn conflict_resolution_open_fallback_when_common() { + // GPU A matches both closed and open; GPU B matches only open. + // The algorithm walks GPU A's profiles in priority order, rejects closed + // (GPU B doesn't have it), then accepts open as the common driver. + let closed_profile = nvidia_profile("nvidia-dkms-580xx", 12, Some("nvidia")); + let open_profile = nvidia_profile("nvidia-open-dkms", 10, Some("nvidia")); + + let gpu_a = gpu_device("0000:01:00.0", vec![closed_profile, open_profile.clone()]); + let gpu_b = gpu_device("0000:09:00.0", vec![open_profile.clone()]); + + let devices = vec![gpu_a, gpu_b]; + + let result = data::resolve_conflict_groups(&devices); + + assert_eq!(result.device_profile_map.len(), 2); + assert_eq!(result.device_profile_map[0].1, "nvidia-open-dkms"); + assert_eq!(result.device_profile_map[1].1, "nvidia-open-dkms"); + assert!(result.skipped_devices.is_empty()); + } + + #[test] + fn conflict_resolution_no_conflict_group_unaffected() { + let amd_profile = crate::profile::Profile { + name: "amd".to_owned(), + priority: 4, + packages: "mesa".to_owned(), + driver_conflict_group: None, + ..Default::default() + }; + let device = gpu_device("0000:01:00.0", vec![amd_profile]); + + let result = data::resolve_conflict_groups(&[device]); + + assert!(result.device_profile_map.is_empty()); + assert!(result.skipped_devices.is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index 03cd9ca..cc5a8de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,6 +151,8 @@ fn prepare_autoconfigure( args: &mut args::Args, autoconf_class_id: &str, ) -> Vec { + use std::collections::{HashMap, HashSet}; + if args.autoconfigure.is_none() { return vec![]; } @@ -159,8 +161,18 @@ fn prepare_autoconfigure( let installed_profiles = data.installed_profiles(); - let all_devices = - data.pci_devices.iter().chain(data.usb_devices.iter()).collect::>(); + let all_devices = data.pci_devices.iter().chain(data.usb_devices.iter()).collect::>(); + + // Pre-compute conflict group resolution for all devices. + // This determines the correct single driver when multiple devices share a conflict group. + let resolution = data::resolve_conflict_groups(&data.pci_devices); + let resolved_map: HashMap<&str, &str> = resolution + .device_profile_map + .iter() + .map(|(busid, name)| (busid.as_str(), name.as_str())) + .collect(); + let skipped_busids: HashSet<&str> = + resolution.skipped_devices.iter().map(std::string::String::as_str).collect(); let mut found_device = false; for device in &all_devices { @@ -179,25 +191,56 @@ fn prepare_autoconfigure( } let profile = profile.unwrap(); + // Check if this device was skipped due to conflict group incompatibility + if skipped_busids.contains(device.sysfs_busid.as_str()) { + log::warn!("Skipping device {device_info}. incompatible driver conflict group"); + continue; + } + + // Determine the effective profile: use conflict resolution if applicable + let effective_profile_name = + resolved_map.get(device.sysfs_busid.as_str()).copied().unwrap_or(&profile.name); + + // Find the effective profile from device's available profiles (or use the best one) + let effective_profile = device + .available_profiles + .iter() + .find(|p| p.name == effective_profile_name) + .unwrap_or(profile); + // If force is not set, then we skip found profile - let skip = !args.force && installed_profiles.iter().any(|x| x.name == profile.name); + let skip = + !args.force && installed_profiles.iter().any(|x| x.name == effective_profile.name); // Print found profile if skip { log::info!( "Skipping already installed profile '{}' for device: {device_info}", + effective_profile.name + ); + } else if effective_profile.name != profile.name { + log::info!( + "Using profile '{}' (conflict group resolution) for device: {device_info} \ + (per-device best was '{}')", + effective_profile.name, profile.name ); } else { - log::info!("Using profile '{}' for device: {device_info}", profile.name); + log::info!("Using profile '{}' for device: {device_info}", effective_profile.name); } - let profile_exists = profiles_with_priority.iter().any(|x| x.1 == profile.name); + let profile_exists = profiles_with_priority.iter().any(|x| x.1 == effective_profile.name); if !profile_exists && !skip { - profiles_with_priority.push((profile.priority, profile.name.clone())); + profiles_with_priority + .push((effective_profile.priority, effective_profile.name.clone())); } } + // Print conflict group warnings + for warning in &resolution.warnings { + log::warn!("{warning}"); + } + // Sort by priority descending so higher-priority profiles (e.g. GPU) // are installed before lower-priority ones (e.g. handhelds). profiles_with_priority.sort_by_key(|b| std::cmp::Reverse(b.0)); @@ -384,7 +427,7 @@ fn install_profile(data: &mut data::Data, args: &args::Args, profile: &Profile) ); let _ = fs::create_dir_all(&working_dir); if !profile::write_profile_to_file( - &format!("{}/{}", &working_dir, consts::CHWD_CONFIG_FILE), + &format!("{}/{}", working_dir, consts::CHWD_CONFIG_FILE), profile, ) { return misc::Status::ErrorSetDatabase; diff --git a/src/misc.rs b/src/misc.rs index 173b841..b569e01 100644 --- a/src/misc.rs +++ b/src/misc.rs @@ -74,8 +74,7 @@ pub fn check_environment() -> Vec { if !Path::new(consts::CHWD_PCI_DATABASE_DIR).exists() { missing_dirs.push(consts::CHWD_PCI_DATABASE_DIR.to_owned()); } - // USB directories are optional — not all installations will have them yet. - // fill_profiles() already handles missing directories gracefully. + // USB directories are optional. missing_dirs } diff --git a/src/profile.rs b/src/profile.rs index d6a9249..4527a62 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -50,6 +50,7 @@ pub struct Profile { pub cpu_models: Option>, pub chassis_types: Option>, pub environment_types: Option>, + pub driver_conflict_group: Option, pub hwd_ids: Vec, } @@ -75,6 +76,7 @@ impl Default for Profile { cpu_models: None, chassis_types: None, environment_types: None, + driver_conflict_group: None, hwd_ids: vec![Default::default()], } } @@ -253,11 +255,13 @@ fn parse_profile(node: &toml::Table, profile_name: &str) -> Result { cpu_models: parse_whitespace_list(node, "cpu_models"), chassis_types: parse_whitespace_list(node, "chassis_types"), environment_types: parse_whitespace_list(node, "environment_types"), + driver_conflict_group: node + .get("driver_conflict_group") + .and_then(|x| x.as_str().map(str::to_string)), }; if profile.cpu_models.is_some() && profile.cpu_family.is_none() { - let msg = - format!("profile '{profile_name}' specifies cpu_models without cpu_family"); + let msg = format!("profile '{profile_name}' specifies cpu_models without cpu_family"); eprintln!("Warning: skipping profile '{profile_name}': {msg}"); anyhow::bail!(msg); } @@ -463,6 +467,9 @@ fn profile_into_toml(profile: &Profile) -> toml::Table { if let Some(environment_types) = &profile.environment_types { table.insert("environment_types".to_owned(), environment_types.join(" ").into()); } + if let Some(conflict_group) = &profile.driver_conflict_group { + table.insert("driver_conflict_group".to_owned(), conflict_group.clone().into()); + } let last_hwd_id = profile.hwd_ids.last().unwrap();