Extend output profiles with monitor reconciliation and mirroring
This commit is contained in:
1 parent
ac184576f6
commit
d14cd3cbd2
23 files changed
+3614
-474
No files matched your search
@@ -0,0 +1,215 @@
|
||||
//! Human-readable EDID identities. Serial-less displays remain tied to a qualified connector.
|
||||
//! Duplicate serials are qualified too; a remembered collision stays qualified after unplug.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Identity {
|
||||
pub make: String,
|
||||
pub model: String,
|
||||
pub serial: Option<String>,
|
||||
}
|
||||
|
||||
fn escaped(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
|
||||
char::from(byte).to_string()
|
||||
} else {
|
||||
format!("%{byte:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
pub fn from_edid(edid: &[u8]) -> Option<Self> {
|
||||
if edid.len() < 128
|
||||
|| edid[..8] != [0, 255, 255, 255, 255, 255, 255, 0]
|
||||
|| edid[..128].iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte)) != 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let manufacturer = u16::from_be_bytes([edid[8], edid[9]]);
|
||||
let mut make = String::new();
|
||||
for shift in [10, 5, 0] {
|
||||
let letter = ((manufacturer >> shift) & 31) as u8;
|
||||
if !(1..=26).contains(&letter) {
|
||||
return None;
|
||||
}
|
||||
make.push(char::from(b'A' + letter - 1));
|
||||
}
|
||||
let product = u16::from_le_bytes([edid[10], edid[11]]);
|
||||
let numeric_serial = u32::from_le_bytes(edid[12..16].try_into().ok()?);
|
||||
let mut model = format!("{product:04X}");
|
||||
let mut serial = None;
|
||||
for descriptor in edid[54..126].chunks_exact(18) {
|
||||
if descriptor[..3] != [0, 0, 0] {
|
||||
continue;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&descriptor[5..18])
|
||||
.trim_matches(|c: char| c.is_whitespace() || c == '\0')
|
||||
.to_owned();
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match descriptor[3] {
|
||||
0xfc => model = format!("{product:04X}-{}", escaped(&text)),
|
||||
0xff if text != "0" => serial = Some(escaped(&text)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if serial.is_none() && numeric_serial != 0 && numeric_serial != u32::MAX {
|
||||
serial = Some(format!("{numeric_serial:08X}"));
|
||||
}
|
||||
Some(Self { make, model, serial })
|
||||
}
|
||||
|
||||
pub fn base(&self) -> String {
|
||||
format!(
|
||||
"edid:{}:{}:{}",
|
||||
self.make,
|
||||
self.model,
|
||||
self.serial.as_deref().unwrap_or("no-serial")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IdentityRegistry {
|
||||
ambiguous: HashSet<String>,
|
||||
/// Retains the first monitor's identity if a later collision is discovered.
|
||||
known: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl IdentityRegistry {
|
||||
pub fn resolve(&mut self, monitors: &[(String, Option<Identity>)]) -> Vec<String> {
|
||||
let mut counts = HashMap::new();
|
||||
for (_, identity) in monitors {
|
||||
if let Some(identity) = identity {
|
||||
*counts.entry(identity.base()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
self.ambiguous
|
||||
.extend(counts.into_iter().filter(|(_, count)| *count > 1).map(|(base, _)| base));
|
||||
// Prefer already-known identities, but never permit duplicate runtime IDs.
|
||||
let mut used = HashSet::new();
|
||||
let mut ordered = monitors.iter().enumerate().collect::<Vec<_>>();
|
||||
ordered.sort_by_key(|(_, (key, _))| (!self.known.contains_key(key), key));
|
||||
let mut result = vec![String::new(); monitors.len()];
|
||||
for (index, (key, identity)) in ordered {
|
||||
let base = identity.as_ref().map(Identity::base);
|
||||
let qualified = match &base {
|
||||
Some(base) => format!("{base}@{}", escaped(key)),
|
||||
None => format!("drm:{}", escaped(key)),
|
||||
};
|
||||
let preferred = self
|
||||
.known
|
||||
.get(key)
|
||||
.filter(|known| {
|
||||
base.as_ref().is_some_and(|base| *known == base || *known == &qualified)
|
||||
|| (base.is_none() && *known == &qualified)
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
if identity.as_ref().is_some_and(|identity| identity.serial.is_some())
|
||||
&& !self.ambiguous.contains(base.as_ref().unwrap())
|
||||
{
|
||||
base.clone().unwrap()
|
||||
} else {
|
||||
qualified.clone()
|
||||
}
|
||||
});
|
||||
let resolved = if used.insert(preferred.clone()) {
|
||||
preferred
|
||||
} else {
|
||||
qualified
|
||||
};
|
||||
assert!(
|
||||
result.iter().all(|other| other != &resolved),
|
||||
"qualified hardware connector must be unique"
|
||||
);
|
||||
self.known.insert(key.clone(), resolved.clone());
|
||||
result[index] = resolved;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity(serial: Option<&str>) -> Identity {
|
||||
Identity {
|
||||
make: "DEL".into(),
|
||||
model: "1234-monitor".into(),
|
||||
serial: serial.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_on_same_connector_invalidates_cached_identity() {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let key = "gpu/DP-1".to_owned();
|
||||
let dell = identity(Some("123"));
|
||||
let mut lg = identity(Some("456"));
|
||||
lg.make = "GSM".into();
|
||||
let old = registry.resolve(&[(key.clone(), Some(dell.clone()))]);
|
||||
let new = registry.resolve(&[(key.clone(), Some(lg))]);
|
||||
assert_ne!(old, new);
|
||||
assert!(new[0].starts_with("edid:GSM:"));
|
||||
assert_eq!(registry.resolve(&[(key.clone(), Some(dell))]), old);
|
||||
assert!(registry.resolve(&[(key, None)])[0].starts_with("drm:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_survives_connector_change() {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let first = registry.resolve(&[("gpu/DP-1".into(), Some(identity(Some("123"))))]);
|
||||
let next = registry.resolve(&[("gpu/DP-2".into(), Some(identity(Some("123"))))]);
|
||||
assert_eq!(first, next);
|
||||
assert_eq!(first[0], "edid:DEL:1234-monitor:123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_without_serial_are_distinct_and_reconnect_stably() {
|
||||
for serial in [None, Some("duplicated")] {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let inputs = [
|
||||
("gpu/DP-1".into(), Some(identity(serial))),
|
||||
("gpu/DP-2".into(), Some(identity(serial))),
|
||||
];
|
||||
let resolved = registry.resolve(&inputs);
|
||||
assert_ne!(resolved[0], resolved[1]);
|
||||
assert_eq!(registry.resolve(&inputs[..1])[0], resolved[0]);
|
||||
assert_eq!(registry.resolve(&inputs), resolved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collision_after_first_connection_does_not_change_existing_identity() {
|
||||
let mut registry = IdentityRegistry::default();
|
||||
let a = ("gpu/DP-1".into(), Some(identity(Some("duplicate"))));
|
||||
let b = ("gpu/DP-2".into(), Some(identity(Some("duplicate"))));
|
||||
let first = registry.resolve(std::slice::from_ref(&a));
|
||||
let both = registry.resolve(&[a.clone(), b]);
|
||||
assert_eq!(both[0], first[0]);
|
||||
assert_ne!(both[0], both[1]);
|
||||
assert_eq!(registry.resolve(&[a]), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edid_decodes_make_product_and_numeric_serial_and_checks_checksum() {
|
||||
let mut edid = [0u8; 128];
|
||||
edid[..8].copy_from_slice(&[0, 255, 255, 255, 255, 255, 255, 0]);
|
||||
let del = (4u16 << 10) | (5 << 5) | 12;
|
||||
edid[8..10].copy_from_slice(&del.to_be_bytes());
|
||||
edid[10..12].copy_from_slice(&0x1234u16.to_le_bytes());
|
||||
edid[12..16].copy_from_slice(&42u32.to_le_bytes());
|
||||
edid[127] = 0u8.wrapping_sub(edid.iter().fold(0u8, |sum, byte| sum.wrapping_add(*byte)));
|
||||
assert_eq!(Identity::from_edid(&edid).unwrap().base(), "edid:DEL:1234:0000002A");
|
||||
edid[127] ^= 1;
|
||||
assert!(Identity::from_edid(&edid).is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user