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}