diff --git a/Cargo.lock b/Cargo.lock index 008c200..28484c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -463,6 +463,7 @@ dependencies = [ "serde", "shell-words", "simplelog", + "udev", "uinput", "vergen-git2", "winapi", diff --git a/Cargo.toml b/Cargo.toml index 03f8154..b67bdc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ ddc-macos = "0.2.2" [target.'cfg(target_os = "linux")'.dependencies] ddc-i2c = "0.2" +udev = "0.2" uinput = "0.1" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/README.md b/README.md index 71bdf87..08bc4c0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,26 @@ The app should function on MacOS, Windows, and Linux. brew install haimgel/tools/display_switch ``` +### Linux: device permissions + +Enable user access to I2C and input devices. Run as root: + +```bash +groupadd i2c +echo 'KERNEL=="i2c-[0-9]*", GROUP="i2c"' >> /etc/udev/rules.d/10-local_i2c_group.rules +groupadd uinput +echo 'KERNEL=="uinput", GROUP="uinput", MODE="0660"' >> /etc/udev/rules.d/10-local_uinput_group.rules +udevadm control --reload-rules && udevadm trigger +``` + +Then add your user to the i2c and uinput groups: + +```bash +sudo usermod -aG i2c,uinput $(whoami) +``` + +And finally restart your session. + ## Configuration The configuration is pretty similar on all platforms: @@ -180,19 +200,6 @@ Copy built executable: ```bash cp target/release/display_switch /usr/local/bin/ ``` -Enable read/write access to i2c devices for users in `i2c` group. Run as root : - -```bash -groupadd i2c -echo 'KERNEL=="i2c-[0-9]*", GROUP="i2c"' >> /etc/udev/rules.d/10-local_i2c_group.rules -udevadm control --reload-rules && udevadm trigger -``` - -Then add your user to the i2c group : - -``` -sudo usermod -aG i2c $(whoami) -``` Create a systemd unit file in your user directory (`/home/$USER/.config/systemd/user/display-switch.service`) with contents diff --git a/src/app.rs b/src/app.rs index 2637455..f9c6970 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,7 +22,7 @@ impl usb::UsbCallback for App { if device_id == self.config.usb_device { info!("Monitored device ({:?}) is connected", &self.config.usb_device); std::thread::spawn(|| { - wake_displays().map_err(|err| error!("{:?}", err)); + wake_displays().map_err(|err| warn!("Couldn't wake displays: {:?}", err)); }); display_control::switch(&self.config, SwitchDirection::Connect); } diff --git a/src/display_control.rs b/src/display_control.rs index 1621dd6..5fbe1c6 100644 --- a/src/display_control.rs +++ b/src/display_control.rs @@ -8,13 +8,26 @@ use crate::input_source::InputSource; use anyhow::{Error, Result}; use ddc_hi::{Ddc, Display, Handle}; +#[cfg(target_os = "linux")] +use std::collections::HashMap; use std::collections::HashSet; use std::process::{Command, Stdio}; use std::{thread, time}; +#[cfg(target_os = "linux")] +use std::ffi::OsStr; +#[cfg(target_os = "linux")] +use std::os::unix::fs::MetadataExt; +#[cfg(target_os = "linux")] +use std::sync::{Mutex, OnceLock}; +#[cfg(target_os = "linux")] +use udev::DeviceType::CharacterDevice; + /// VCP feature code for input select const INPUT_SELECT: u8 = 0x60; const RETRY_DELAY_MS: u64 = 3000; +const GET_CURRENT_INPUT_RETRY_ATTEMPTS: usize = 5; +const GET_CURRENT_INPUT_RETRY_DELAY_MS: u64 = 100; fn display_name(display: &Display, index: Option) -> String { // Different OSes populate different fields of ddc-hi-rs info structure differently. Create @@ -47,8 +60,31 @@ fn are_display_names_unique(displays: &[Display]) -> bool { displays.iter().all(|display| hash.insert(display_name(display, None))) } +fn get_current_input(handle: &mut Handle, display_name: &str) -> Result { + let retry_delay = time::Duration::from_millis(GET_CURRENT_INPUT_RETRY_DELAY_MS); + + for attempt in 1.. { + match handle.get_vcp_feature(INPUT_SELECT) { + Ok(raw_source) => return Ok(raw_source), + Err(err) => { + if attempt >= GET_CURRENT_INPUT_RETRY_ATTEMPTS { + return Err(err); + } + + debug!( + "Failed to get current input for display {} on attempt {}/{}: {:?}", + display_name, attempt, GET_CURRENT_INPUT_RETRY_ATTEMPTS, err + ); + thread::sleep(retry_delay); + } + } + } + + unreachable!() +} + fn try_switch_display(handle: &mut Handle, display_name: &str, input: InputSource) { - match handle.get_vcp_feature(INPUT_SELECT) { + match get_current_input(handle, display_name) { Ok(raw_source) => { if raw_source.value() & 0xff == input.value() { info!("Display {} is already set to {}", display_name, input); @@ -70,8 +106,75 @@ fn try_switch_display(handle: &mut Handle, display_name: &str, input: InputSourc } } +#[cfg(target_os = "linux")] +fn display_connector_name(display: &Display) -> Option { + let Handle::I2cDevice(i2c) = &display.handle; + let file = i2c.inner_ref().inner_ref(); + let devnum = file.metadata().ok()?.rdev(); + + let context = udev::Context::new().ok()?; + let mut device = context.device_from_devnum(CharacterDevice, devnum).ok()?; + + loop { + if device.subsystem() == Some(OsStr::new("drm")) { + let name = device.sysname().to_str()?; + if name.starts_with("card") && name.contains('-') { + return Some(name.to_owned()); + } + } + + device = device.parent()?; + } +} + +#[cfg(target_os = "linux")] +fn is_laptop_connector_name(name: &str) -> bool { + name.contains("-eDP-") || name.contains("-LVDS-") +} + +#[cfg(target_os = "linux")] +fn filtered_displays() -> Vec { + static SKIPPED_DISPLAYS: OnceLock>> = OnceLock::new(); + let mut skipped_displays = SKIPPED_DISPLAYS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let mut displays = Vec::new(); + for display in Display::enumerate() { + let display_id = display.info.id.clone(); + if let Some(was_skipped) = skipped_displays.get(&display_id).copied() { + if !was_skipped { + displays.push(display); + } + continue; + } + + let connector = display_connector_name(&display); + let is_skipped = connector.as_deref().is_some_and(is_laptop_connector_name); + skipped_displays.insert(display_id, is_skipped); + if !is_skipped { + displays.push(display); + continue; + } + + info!( + "Skipping built-in laptop display {} on connector '{}'", + display_name(&display, None), + connector.unwrap(), + ); + } + + displays +} + +#[cfg(not(target_os = "linux"))] +fn filtered_displays() -> Vec { + Display::enumerate() +} + fn displays() -> Vec { - let displays = Display::enumerate(); + let displays = filtered_displays(); if !displays.is_empty() { return displays; } @@ -85,7 +188,7 @@ fn displays() -> Vec { delay_duration.as_secs() ); thread::sleep(delay_duration); - Display::enumerate() + filtered_displays() } pub fn log_current_source() { @@ -97,9 +200,9 @@ pub fn log_current_source() { let unique_names = are_display_names_unique(&displays); for (index, mut display) in displays.into_iter().enumerate() { let display_name = display_name(&display, if unique_names { None } else { Some(index + 1) }); - match display.handle.get_vcp_feature(INPUT_SELECT) { + match get_current_input(&mut display.handle, &display_name) { Ok(raw_source) => { - let source = InputSource::from(raw_source.value()); + let source = InputSource::from(raw_source); info!("Display {} is currently set to {}", display_name, source); } Err(err) => { diff --git a/src/input_source.rs b/src/input_source.rs index f9cba64..fe3b027 100644 --- a/src/input_source.rs +++ b/src/input_source.rs @@ -3,6 +3,7 @@ // This code is licensed under MIT license (see LICENSE.txt for details) // +use ddc_hi::VcpValue; use paste::paste; use serde::de::Error; use serde::{Deserialize, Deserializer}; @@ -116,6 +117,13 @@ impl From for InputSource { } } +impl From for InputSource { + fn from(value: VcpValue) -> Self { + // Some VDUs don't zero the SNC high-byte like input source (0x60). + Self::from(value.sl as u16) + } +} + impl fmt::Display for SymbolicInputSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{:?}", self) diff --git a/src/platform/wake_displays.rs b/src/platform/wake_displays.rs index 5c532ae..f5150de 100644 --- a/src/platform/wake_displays.rs +++ b/src/platform/wake_displays.rs @@ -40,6 +40,7 @@ pub fn wake_displays() -> Result<()> { #[cfg(target_os = "linux")] pub fn wake_displays() -> Result<()> { + use anyhow::Context; use std::{thread, time}; use uinput::event::controller::Controller::Mouse; use uinput::event::controller::Mouse::Left; @@ -47,7 +48,8 @@ pub fn wake_displays() -> Result<()> { use uinput::event::relative::Relative::Position; use uinput::event::Event::{Controller, Relative}; - let mut device = uinput::default()? + let mut device = uinput::default() + .context("can't open default uinput device")? .name("display-switch")? // It's necessary to enable any mouse button. Otherwise Relative events would not work. .event(Controller(Mouse(Left)))?