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, color 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, or one with a
82/// zero maximum or a minimum above its maximum, which is not the same as a
83/// panel with no range -- it is a panel that did not say.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[allow(
86 clippy::exhaustive_structs,
87 reason = "a value defined by its fields; there is nothing to add to it"
88)]
89pub struct VrefreshRange {
90 /// Lowest refresh in Hz.
91 pub min_hz: i32,
92 /// Highest refresh in Hz.
93 pub max_hz: i32,
94}
95
96impl VrefreshRange {
97 /// Whether the panel accepts more than one rate.
98 ///
99 /// The question a compositor is really asking before it offers
100 /// variable-refresh presentation.
101 #[must_use]
102 pub const fn is_variable(&self) -> bool {
103 self.max_hz > self.min_hz
104 }
105}
106
107/// What a connector's attached display reports.
108#[derive(Debug, Clone, PartialEq, Default)]
109#[non_exhaustive]
110pub struct ConnectorInfo {
111 /// Make and model joined, for display to a person.
112 pub name: String,
113 /// Manufacturer — a company name where the PNP ID is known, else the ID.
114 pub make: String,
115 /// Product name or model.
116 pub model: String,
117 /// Per-unit serial number, where the display gives one.
118 pub serial: Option<String>,
119 /// Physical width in millimeters.
120 pub width_mm: i32,
121 /// Physical height in millimeters.
122 pub height_mm: i32,
123 /// Color primaries, where present.
124 pub colorimetry: Option<ColorimetryInfo>,
125 /// HDR static metadata, where the display advertises any.
126 pub hdr: Option<HdrStaticMetadata>,
127 /// Extra colorimetry encodings, where any are advertised.
128 pub wide_gamut: Option<SupportedColorimetry>,
129 /// The refresh range the panel states, where it states one.
130 pub vrefresh_range: Option<VrefreshRange>,
131}
132
133/// Why an EDID blob could not be read.
134#[derive(Debug, thiserror::Error)]
135#[non_exhaustive]
136pub enum EdidError {
137 /// The blob was not parseable as EDID at all.
138 ///
139 /// A truncated read, a blob that is not EDID, or one malformed past what
140 /// the parser is willing to guess at.
141 #[error("EDID blob could not be parsed")]
142 Unparseable,
143}
144
145/// Read a display's EDID blob.
146///
147/// # Errors
148///
149/// [`EdidError::Unparseable`] if the blob is not EDID.
150pub fn parse_edid(blob: &[u8]) -> Result<ConnectorInfo, EdidError> {
151 let info = Info::parse_edid(blob).map_err(|_| EdidError::Unparseable)?;
152
153 let make = info.make().unwrap_or_default();
154 let model = info.model().unwrap_or_default();
155 // Joined the way upstream joins them, with a space only when both halves
156 // are there — a display that reports only a model should read as its model,
157 // not as a leading space.
158 let name = match (make.is_empty(), model.is_empty()) {
159 (false, false) => format!("{make} {model}"),
160 (false, true) => make.clone(),
161 (true, false) => model.clone(),
162 (true, true) => String::new(),
163 };
164
165 let (mut width_mm, mut height_mm) = (0, 0);
166 if let Some(edid) = info.edid() {
167 let size = edid.screen_size();
168 // EDID records whole centimeters; everything downstream works in
169 // millimeters, so convert here rather than leaving two units in play.
170 width_mm = size.width_cm.unwrap_or(0) * 10;
171 height_mm = size.height_cm.unwrap_or(0) * 10;
172 }
173
174 let primaries = info.default_color_primaries();
175 let colorimetry =
176 (primaries.has_primaries || primaries.has_default_white_point).then(|| ColorimetryInfo {
177 red: Chromaticity {
178 x: primaries.primary[0].x,
179 y: primaries.primary[0].y,
180 },
181 green: Chromaticity {
182 x: primaries.primary[1].x,
183 y: primaries.primary[1].y,
184 },
185 blue: Chromaticity {
186 x: primaries.primary[2].x,
187 y: primaries.primary[2].y,
188 },
189 white: Chromaticity {
190 x: primaries.default_white.x,
191 y: primaries.default_white.y,
192 },
193 has_primaries: primaries.has_primaries,
194 has_default_white: primaries.has_default_white_point,
195 });
196
197 let raw_hdr = info.hdr_static_metadata();
198 // `traditional_sdr` is deliberately absent from this test.
199 // libdisplay-info sets it for any base EDID, so a gate that counted it
200 // would be `Some` for every display ever made — the defect upstream had
201 // until `1b435c3`, arrived at differently. This is *HDR* static metadata:
202 // traditional SDR gamma alone means the display said nothing about HDR.
203 let advertises_hdr = raw_hdr.type1 || raw_hdr.traditional_hdr || raw_hdr.pq || raw_hdr.hlg;
204 let hdr = advertises_hdr.then_some(HdrStaticMetadata {
205 desired_content_max_luminance: raw_hdr.desired_content_max_luminance,
206 desired_content_max_frame_avg_luminance: raw_hdr.desired_content_max_frame_avg_luminance,
207 desired_content_min_luminance: raw_hdr.desired_content_min_luminance,
208 type1: raw_hdr.type1,
209 traditional_sdr: raw_hdr.traditional_sdr,
210 traditional_hdr: raw_hdr.traditional_hdr,
211 pq: raw_hdr.pq,
212 hlg: raw_hdr.hlg,
213 });
214
215 let raw_gamut = info.supported_signal_colorimetry();
216 let any_gamut = raw_gamut.bt2020_cycc
217 || raw_gamut.bt2020_ycc
218 || raw_gamut.bt2020_rgb
219 || raw_gamut.st2113_rgb
220 || raw_gamut.ictcp;
221 let wide_gamut = any_gamut.then_some(SupportedColorimetry {
222 bt2020_cycc: raw_gamut.bt2020_cycc,
223 bt2020_ycc: raw_gamut.bt2020_ycc,
224 bt2020_rgb: raw_gamut.bt2020_rgb,
225 st2113_rgb: raw_gamut.st2113_rgb,
226 ictcp: raw_gamut.ictcp,
227 });
228
229 // One descriptor slot in four carries this, and only if the panel used it.
230 // A range of zero to zero is a descriptor that exists and says nothing,
231 // which is the same information as no descriptor at all.
232 let vrefresh_range = info
233 .edid()
234 .and_then(|edid| {
235 edid.display_descriptors()
236 .iter()
237 .find_map(DisplayDescriptorRef::range_limits)
238 })
239 .map(|limits| VrefreshRange {
240 min_hz: limits.min_vert_rate_hz,
241 max_hz: limits.max_vert_rate_hz,
242 })
243 .filter(|range| range.max_hz > 0 && range.min_hz <= range.max_hz);
244
245 Ok(ConnectorInfo {
246 name,
247 make,
248 model,
249 serial: info.serial(),
250 width_mm,
251 height_mm,
252 colorimetry,
253 hdr,
254 wide_gamut,
255 vrefresh_range,
256 })
257}