displays_physical_linux 0.1.0

Linux physical display brightness backends for the displays crate
Documentation
use std::collections::BTreeMap;
use std::sync::mpsc;
use std::time::Duration;

use ddc_hi::{Ddc, Display as DdcDisplay, DisplayInfo, FeatureCode};
use displays_physical_types::{
    PhysicalDisplayMetadata, PhysicalDisplayState, PhysicalDisplayUpdate,
};
use displays_types::Brightness;

use crate::error::{ApplyError, QueryError};
use crate::types::{remaining_update, Backend, DdcApplyUpdate, DisplayHandle};

const PER_MONITOR_APPLY_TIMEOUT: Duration = Duration::from_millis(3500);

pub(crate) fn enumerate_handles() -> Result<Vec<DisplayHandle>, QueryError> {
    let mut handles = Vec::new();

    for (display_index, mut display) in DdcDisplay::enumerate().into_iter().enumerate() {
        let info = display.info.clone();
        let display_id = info.id.clone();
        let brightness = match display.handle.get_vcp_feature(FeatureCode::from(0x10)) {
            Ok(vcp) => {
                let maximum = vcp.maximum();
                if maximum == 0 {
                    tracing::warn!(
                        "Brightness is unavailable for display '{}' because max was reported as 0",
                        display_id
                    );
                    None
                } else {
                    Some(Brightness::new(
                        (((vcp.value() as f64 / maximum as f64) * 100.0).round() as u8).min(100),
                    ))
                }
            }
            Err(err) => {
                let message = err.to_string();
                let detail = if is_io_error(&message) {
                    "I/O error"
                } else {
                    "query error"
                };
                tracing::warn!(
                    "Brightness is unavailable for display '{}' due to {}: {}",
                    display_id,
                    detail,
                    message
                );
                None
            }
        };

        handles.push(DisplayHandle {
            metadata: metadata_from_info(&info),
            state: PhysicalDisplayState { brightness },
            backend: Backend::Ddc { display_index },
        });
    }

    Ok(handles)
}

pub(crate) fn apply_updates(updates: Vec<DdcApplyUpdate>) -> Vec<PhysicalDisplayUpdate> {
    if updates.is_empty() {
        return Vec::new();
    }

    let mut remaining_updates = Vec::new();
    let mut display_by_index: BTreeMap<usize, DdcDisplay> =
        DdcDisplay::enumerate().into_iter().enumerate().collect();

    for update in updates {
        let Some(brightness_percent) = update.content.brightness else {
            continue;
        };

        let Some(display) = display_by_index.remove(&update.display_index) else {
            remaining_updates.push(remaining_update(update.id, brightness_percent));
            continue;
        };

        let display_id = display.info.id.clone();
        if let Err(err) = set_brightness_with_timeout(display, brightness_percent) {
            tracing::warn!(
                "Failed to set brightness for display '{}': {}",
                display_id,
                err
            );
            remaining_updates.push(remaining_update(update.id, brightness_percent));
        }
    }

    remaining_updates
}

fn set_brightness(display: &mut DdcDisplay, brightness_percent: u32) -> Result<(), ApplyError> {
    let ddc_id = display.info.id.clone();
    let normalized = brightness_percent.min(100);
    let target_value = if normalized == 0 {
        0
    } else {
        let vcp = display
            .handle
            .get_vcp_feature(FeatureCode::from(0x10))
            .map_err(|err| classify_apply_error(ddc_id.clone(), err.to_string()))?;

        let max = vcp.maximum();
        if max == 0 {
            return Err(ApplyError::UnsupportedMonitor {
                display_id: ddc_id,
                message: "reported brightness max value is 0".to_string(),
            });
        }

        let percent = normalized as f64 / 100.0;
        (percent * max as f64).round() as u16
    };

    display
        .handle
        .set_vcp_feature(FeatureCode::from(0x10), target_value)
        .map_err(|err| classify_apply_error(display.info.id.clone(), err.to_string()))
}

fn set_brightness_with_timeout(
    display: DdcDisplay,
    brightness_percent: u32,
) -> Result<(), ApplyError> {
    let display_id = display.info.id.clone();
    let (sender, receiver) = mpsc::channel();
    std::thread::spawn(move || {
        let mut display = display;
        let result = set_brightness(&mut display, brightness_percent);
        let _ = sender.send(result);
    });

    match receiver.recv_timeout(PER_MONITOR_APPLY_TIMEOUT) {
        Ok(result) => result,
        Err(mpsc::RecvTimeoutError::Timeout) => Err(ApplyError::DdcOperation {
            display_id,
            message: format!("timed out after {:?}", PER_MONITOR_APPLY_TIMEOUT),
        }),
        Err(mpsc::RecvTimeoutError::Disconnected) => Err(ApplyError::DdcOperation {
            display_id,
            message: "apply worker disconnected unexpectedly".to_string(),
        }),
    }
}

fn metadata_from_info(info: &DisplayInfo) -> PhysicalDisplayMetadata {
    let model = info
        .model_name
        .clone()
        .or_else(|| info.model_id.map(|model_id| format!("0x{model_id:04X}")));
    let name = info
        .model_name
        .clone()
        .unwrap_or_else(|| format!("Display {}", info.id));

    let serial_number = info.serial_number.clone().or_else(|| {
        info.serial
            .filter(|serial| *serial != 0)
            .map(|serial| serial.to_string())
    });

    PhysicalDisplayMetadata {
        path: info.id.clone(),
        name,
        manufacturer: info.manufacturer_id.clone(),
        model,
        serial_number,
    }
}

#[cfg(test)]
mod tests {
    use ddc_hi::{Backend, DisplayInfo};

    use super::metadata_from_info;

    #[test]
    fn metadata_from_info_uses_available_display_fields() {
        let info = DisplayInfo {
            backend: Backend::I2cDevice,
            id: "/dev/i2c-7".to_string(),
            manufacturer_id: Some("DEL".to_string()),
            model_id: Some(0x1234),
            version: None,
            serial: Some(42),
            manufacture_year: None,
            manufacture_week: None,
            model_name: Some("U2723QE".to_string()),
            serial_number: Some("ABC123".to_string()),
            edid_data: None,
            mccs_version: None,
            mccs_database: Default::default(),
        };

        let metadata = metadata_from_info(&info);

        assert_eq!(metadata.path, "/dev/i2c-7");
        assert_eq!(metadata.name, "U2723QE");
        assert_eq!(metadata.manufacturer.as_deref(), Some("DEL"));
        assert_eq!(metadata.model.as_deref(), Some("U2723QE"));
        assert_eq!(metadata.serial_number.as_deref(), Some("ABC123"));
    }
}

fn classify_apply_error(display_id: String, message: String) -> ApplyError {
    let lowercase = message.to_lowercase();
    if lowercase.contains("permission denied") {
        return ApplyError::PermissionDenied { display_id };
    }
    if lowercase.contains("/dev/i2c") || lowercase.contains("no such file") {
        return ApplyError::MissingI2cAccess { display_id };
    }
    if lowercase.contains("unsupported") || lowercase.contains("vcp") {
        return ApplyError::UnsupportedMonitor {
            display_id,
            message,
        };
    }
    ApplyError::DdcOperation {
        display_id,
        message,
    }
}

fn is_io_error(message: &str) -> bool {
    let lowercase = message.to_lowercase();
    lowercase.contains("input/output error") || lowercase.contains("os error 5")
}