Skip to main content

drmkit_display/
edid.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! What a connected display says about itself.
5//!
6//! EDID is a blob the display hands over on connect, describing its make,
7//! physical size, colour primaries, and what HDR it can accept. It is also
8//! notoriously malformed in the field — truncated, mis-checksummed, carrying
9//! extension blocks that disagree with the base block — so parsing it is
10//! delegated to [`libdisplay-info`], which exists precisely because everyone
11//! who wrote their own got it wrong.
12//!
13//! This module is the translation layer: `libdisplay-info`'s view into the
14//! shapes the rest of drmkit uses, with the same field selection upstream
15//! makes.
16//!
17//! Behind the `edid` feature, because the binding links a system library and
18//! the cross lanes have no sysroot for it.
19//!
20//! [`libdisplay-info`]: https://gitlab.freedesktop.org/emersion/libdisplay-info
21
22use libdisplay_info::edid::DisplayDescriptorRef;
23use libdisplay_info::info::Info;
24
25pub use crate::color::{Chromaticity, ColorimetryInfo};
26
27/// The display's HDR static metadata block.
28///
29/// One field per flag in the CTA block, rather than a bitflags type: this is a
30/// transcription of a wire format, and a reader checking it against the
31/// standard should find the standard's field names.
32#[derive(Debug, Clone, Copy, PartialEq, Default)]
33#[expect(
34    clippy::struct_excessive_bools,
35    reason = "the shape is CTA-861's, not a design choice"
36)]
37#[non_exhaustive]
38pub struct HdrStaticMetadata {
39    /// Peak luminance the display wants content mastered to, in nits.
40    pub desired_content_max_luminance: f32,
41    /// Desired frame-average peak, in nits.
42    pub desired_content_max_frame_avg_luminance: f32,
43    /// Desired black level, in nits.
44    pub desired_content_min_luminance: f32,
45    /// Static metadata type 1 is supported.
46    pub type1: bool,
47    /// Traditional SDR gamma.
48    pub traditional_sdr: bool,
49    /// Traditional HDR gamma.
50    pub traditional_hdr: bool,
51    /// SMPTE ST 2084 — HDR10.
52    pub pq: bool,
53    /// Hybrid Log-Gamma.
54    pub hlg: bool,
55}
56
57/// Signal colorimetry encodings the display accepts beyond the default.
58#[derive(Debug, Clone, Copy, PartialEq, Default)]
59#[expect(
60    clippy::struct_excessive_bools,
61    reason = "one field per encoding in the CTA colorimetry data block"
62)]
63#[non_exhaustive]
64pub struct SupportedColorimetry {
65    /// BT.2020 constant-luminance `YCbCr`.
66    pub bt2020_cycc: bool,
67    /// BT.2020 non-constant-luminance `YCbCr`.
68    pub bt2020_ycc: bool,
69    /// BT.2020 RGB.
70    pub bt2020_rgb: bool,
71    /// SMPTE ST 2113 — P3D65 and P3DCI.
72    pub st2113_rgb: bool,
73    /// BT.2100 `ICtCp`.
74    pub ictcp: bool,
75}
76
77/// The vertical refresh range the panel states it accepts.
78///
79/// From the EDID Display Range Limits descriptor, tag `0xFD`. On a
80/// variable-refresh panel this is its VRR window; on a fixed-rate one the two
81/// are equal. Absent when the EDID carries no range descriptor, which is not
82/// the same as a panel with no range -- it is a panel that did not say.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[allow(
85    clippy::exhaustive_structs,
86    reason = "a value defined by its fields; there is nothing to add to it"
87)]
88pub struct VrefreshRange {
89    /// Lowest refresh in Hz.
90    pub min_hz: i32,
91    /// Highest refresh in Hz.
92    pub max_hz: i32,
93}
94
95impl VrefreshRange {
96    /// Whether the panel accepts more than one rate.
97    ///
98    /// The question a compositor is really asking before it offers
99    /// variable-refresh presentation.
100    #[must_use]
101    pub const fn is_variable(&self) -> bool {
102        self.max_hz > self.min_hz
103    }
104}
105
106/// What a connector's attached display reports.
107#[derive(Debug, Clone, PartialEq, Default)]
108#[non_exhaustive]
109pub struct ConnectorInfo {
110    /// Make and model joined, for display to a person.
111    pub name: String,
112    /// Manufacturer — a company name where the PNP ID is known, else the ID.
113    pub make: String,
114    /// Product name or model.
115    pub model: String,
116    /// Per-unit serial number, where the display gives one.
117    pub serial: Option<String>,
118    /// Physical width in millimetres.
119    pub width_mm: i32,
120    /// Physical height in millimetres.
121    pub height_mm: i32,
122    /// Colour primaries, where present.
123    pub colorimetry: Option<ColorimetryInfo>,
124    /// HDR static metadata, where the display advertises any.
125    pub hdr: Option<HdrStaticMetadata>,
126    /// Extra colorimetry encodings, where any are advertised.
127    pub wide_gamut: Option<SupportedColorimetry>,
128    /// The refresh range the panel states, where it states one.
129    pub vrefresh_range: Option<VrefreshRange>,
130}
131
132/// Why an EDID blob could not be read.
133#[derive(Debug, thiserror::Error)]
134#[non_exhaustive]
135pub enum EdidError {
136    /// The blob was not parseable as EDID at all.
137    ///
138    /// A truncated read, a blob that is not EDID, or one malformed past what
139    /// the parser is willing to guess at.
140    #[error("EDID blob could not be parsed")]
141    Unparseable,
142}
143
144/// Read a display's EDID blob.
145///
146/// # Errors
147///
148/// [`EdidError::Unparseable`] if the blob is not EDID.
149pub fn parse_edid(blob: &[u8]) -> Result<ConnectorInfo, EdidError> {
150    let info = Info::parse_edid(blob).map_err(|_| EdidError::Unparseable)?;
151
152    let make = info.make().unwrap_or_default();
153    let model = info.model().unwrap_or_default();
154    // Joined the way upstream joins them, with a space only when both halves
155    // are there — a display that reports only a model should read as its model,
156    // not as a leading space.
157    let name = match (make.is_empty(), model.is_empty()) {
158        (false, false) => format!("{make} {model}"),
159        (false, true) => make.clone(),
160        (true, false) => model.clone(),
161        (true, true) => String::new(),
162    };
163
164    let (mut width_mm, mut height_mm) = (0, 0);
165    if let Some(edid) = info.edid() {
166        let size = edid.screen_size();
167        // EDID records whole centimetres; everything downstream works in
168        // millimetres, so convert here rather than leaving two units in play.
169        width_mm = size.width_cm.unwrap_or(0) * 10;
170        height_mm = size.height_cm.unwrap_or(0) * 10;
171    }
172
173    let primaries = info.default_color_primaries();
174    let colorimetry =
175        (primaries.has_primaries || primaries.has_default_white_point).then(|| ColorimetryInfo {
176            red: Chromaticity {
177                x: primaries.primary[0].x,
178                y: primaries.primary[0].y,
179            },
180            green: Chromaticity {
181                x: primaries.primary[1].x,
182                y: primaries.primary[1].y,
183            },
184            blue: Chromaticity {
185                x: primaries.primary[2].x,
186                y: primaries.primary[2].y,
187            },
188            white: Chromaticity {
189                x: primaries.default_white.x,
190                y: primaries.default_white.y,
191            },
192            has_primaries: primaries.has_primaries,
193            has_default_white: primaries.has_default_white_point,
194        });
195
196    let raw_hdr = info.hdr_static_metadata();
197    // `traditional_sdr` is deliberately absent from this test.
198    // libdisplay-info sets it for any base EDID, so a gate that counted it
199    // would be `Some` for every display ever made — the defect upstream had
200    // until `1b435c3`, arrived at differently. This is *HDR* static metadata:
201    // traditional SDR gamma alone means the display said nothing about HDR.
202    let advertises_hdr = raw_hdr.type1 || raw_hdr.traditional_hdr || raw_hdr.pq || raw_hdr.hlg;
203    let hdr = advertises_hdr.then_some(HdrStaticMetadata {
204        desired_content_max_luminance: raw_hdr.desired_content_max_luminance,
205        desired_content_max_frame_avg_luminance: raw_hdr.desired_content_max_frame_avg_luminance,
206        desired_content_min_luminance: raw_hdr.desired_content_min_luminance,
207        type1: raw_hdr.type1,
208        traditional_sdr: raw_hdr.traditional_sdr,
209        traditional_hdr: raw_hdr.traditional_hdr,
210        pq: raw_hdr.pq,
211        hlg: raw_hdr.hlg,
212    });
213
214    let raw_gamut = info.supported_signal_colorimetry();
215    let any_gamut = raw_gamut.bt2020_cycc
216        || raw_gamut.bt2020_ycc
217        || raw_gamut.bt2020_rgb
218        || raw_gamut.st2113_rgb
219        || raw_gamut.ictcp;
220    let wide_gamut = any_gamut.then_some(SupportedColorimetry {
221        bt2020_cycc: raw_gamut.bt2020_cycc,
222        bt2020_ycc: raw_gamut.bt2020_ycc,
223        bt2020_rgb: raw_gamut.bt2020_rgb,
224        st2113_rgb: raw_gamut.st2113_rgb,
225        ictcp: raw_gamut.ictcp,
226    });
227
228    // One descriptor slot in four carries this, and only if the panel used it.
229    // A range of zero to zero is a descriptor that exists and says nothing,
230    // which is the same information as no descriptor at all.
231    let vrefresh_range = info
232        .edid()
233        .and_then(|edid| {
234            edid.display_descriptors()
235                .iter()
236                .find_map(DisplayDescriptorRef::range_limits)
237        })
238        .map(|limits| VrefreshRange {
239            min_hz: limits.min_vert_rate_hz,
240            max_hz: limits.max_vert_rate_hz,
241        })
242        .filter(|range| range.max_hz > 0 && range.min_hz <= range.max_hz);
243
244    Ok(ConnectorInfo {
245        name,
246        make,
247        model,
248        serial: info.serial(),
249        width_mm,
250        height_mm,
251        colorimetry,
252        hdr,
253        wide_gamut,
254        vrefresh_range,
255    })
256}