diff options
author | Mike Crute <mike@crute.us> | 2020-11-25 11:05:04 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2020-11-25 11:05:04 -0800 |
commit | 544696cafd4a904c76d8b528f43097928e8a2388 (patch) | |
tree | 9d3703d7c95d980845990078f2ce16ab7ec83201 /bin | |
parent | b95fe58226e36dadd0602b0ddd35194b4ba02169 (diff) | |
download | dotfiles-544696cafd4a904c76d8b528f43097928e8a2388.tar.bz2 dotfiles-544696cafd4a904c76d8b528f43097928e8a2388.tar.xz dotfiles-544696cafd4a904c76d8b528f43097928e8a2388.zip |
Rewrite display enumeration logic
Eliminates parsing xrandr and the need for parse-edid (which is no
longer in most distro repos). Also fixes a bunch of bugs with parse-edid
name handling.
Diffstat (limited to 'bin')
-rwxr-xr-x | bin/enumerate-displays | 415 |
1 files changed, 362 insertions, 53 deletions
diff --git a/bin/enumerate-displays b/bin/enumerate-displays index 5ede885..db571c7 100755 --- a/bin/enumerate-displays +++ b/bin/enumerate-displays | |||
@@ -1,80 +1,389 @@ | |||
1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
2 | # | 2 | """ |
3 | # TODO: Rewrite this in C and just use the XRandr APIs directly | 3 | Extended Display Identification Data Parser (mostly incomplete) |
4 | # | 4 | by Mike Crute (mike@crute.us) |
5 | licensed under the terms of the MIT license | ||
5 | 6 | ||
6 | import re | 7 | This is basically just a minimum viable implementation of an EDID parser. There |
7 | import sys | 8 | is a weird subset of features supported because I got excited about parsing |
8 | import subprocess | 9 | this wacky binary structure. But there's still a lot left un-implemented |
10 | because I ran out of time and had already satisfied my use-case. | ||
9 | 11 | ||
12 | If you use this and need or want to implement the rest I'll gladly take pull | ||
13 | requests, see the TODOs below. | ||
10 | 14 | ||
11 | def is_host_xps(): | 15 | To use it just call `get_connected_edid` and consider the `EDID` classes |
12 | try: | 16 | returned. |
13 | with open("/sys/class/dmi/id/product_name", "r") as fp: | ||
14 | product = fp.read() | ||
15 | 17 | ||
16 | return bool(re.match("XPS 13 93[78]0", product)) | 18 | Based heavily on the information from the following two websites: |
17 | except FileNotFoundError: | 19 | * https://en.wikipedia.org/wiki/Extended_Display_Identification_Data |
18 | return False | 20 | * https://blog.fpmurphy.com/2011/07/retrieve-edid-information-via-sysfs.html |
21 | """ | ||
22 | import os | ||
23 | import glob | ||
24 | import struct | ||
25 | from collections import defaultdict | ||
19 | 26 | ||
20 | 27 | ||
21 | def parse_edid(hex_list): | 28 | ESTABLISHED_TIMING = { |
22 | proc = subprocess.Popen( | 29 | 0x800000: ( 720, 400, 70), |
23 | ["parse-edid"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, | 30 | 0x400000: ( 720, 400, 88), |
24 | stderr=subprocess.PIPE) | 31 | 0x200000: ( 640, 480, 60), |
32 | 0x100000: ( 640, 480, 67), | ||
33 | 0x80000: ( 640, 480, 72), | ||
34 | 0x40000: ( 640, 480, 75), | ||
35 | 0x20000: ( 800, 600, 56), | ||
36 | 0x10000: ( 800, 600, 60), | ||
37 | 0x8000: ( 800, 600, 72), | ||
38 | 0x4000: ( 800, 600, 75), | ||
39 | 0x2000: ( 832, 624, 75), | ||
40 | 0x1000: (1024, 768, 87), | ||
41 | 0x800: (1024, 768, 60), | ||
42 | 0x400: (1024, 768, 70), | ||
43 | 0x200: (1024, 768, 75), | ||
44 | 0x100: (1280, 1024, 75), | ||
45 | 0x80: (1152, 870, 75), | ||
46 | } | ||
25 | 47 | ||
26 | out, _ = proc.communicate(bytes.fromhex("".join(hex_list))) | 48 | DISPLAY_DESCRIPTOR_ASCII_TYPES = { |
49 | 0xFF: "Serial Number", | ||
50 | 0xFE: "Unspecified Text", | ||
51 | 0xFC: "Display Name", | ||
52 | } | ||
27 | 53 | ||
28 | identifier = re.findall( | 54 | DISPLAY_DESCRIPTOR_TYPES = { |
29 | '\tIdentifier "([^"]+)"\n', | 55 | # 6 or 13-byte (with additional timing) binary descriptor |
30 | out.decode("us-ascii", errors="backslashreplace")) | 56 | 0xFD: "Display Range Limits", |
57 | # 2x 5-byte descriptors, padded with 0A 20 20. | ||
58 | 0xFB: "Additional White Point Data", | ||
59 | # 6x 2-byte descriptors, padded with 0A. | ||
60 | 0xFA: "Additional Standard Timing Identifiers", | ||
61 | 0xF9: "Display Color Management", | ||
62 | 0xF8: "CVT 3-Byte Timing Codes", | ||
63 | 0xF7: "Additional Standard Timing 3", | ||
64 | 0x10: "Dummy Identifier", | ||
65 | 0x00: "Manufacturer Reserved Descriptor", | ||
66 | 0x01: "Manufacturer Reserved Descriptor", | ||
67 | 0x02: "Manufacturer Reserved Descriptor", | ||
68 | 0x03: "Manufacturer Reserved Descriptor", | ||
69 | 0x04: "Manufacturer Reserved Descriptor", | ||
70 | 0x05: "Manufacturer Reserved Descriptor", | ||
71 | 0x06: "Manufacturer Reserved Descriptor", | ||
72 | 0x07: "Manufacturer Reserved Descriptor", | ||
73 | 0x08: "Manufacturer Reserved Descriptor", | ||
74 | 0x09: "Manufacturer Reserved Descriptor", | ||
75 | 0x0A: "Manufacturer Reserved Descriptor", | ||
76 | 0x0B: "Manufacturer Reserved Descriptor", | ||
77 | 0x0C: "Manufacturer Reserved Descriptor", | ||
78 | 0x0D: "Manufacturer Reserved Descriptor", | ||
79 | 0x0E: "Manufacturer Reserved Descriptor", | ||
80 | 0x0F: "Manufacturer Reserved Descriptor", | ||
81 | } | ||
31 | 82 | ||
32 | if identifier: | 83 | BIT_DEPTHS = { |
33 | return identifier[0] | 84 | 0: "undefined", |
85 | 1: 6, | ||
86 | 2: 8, | ||
87 | 3: 10, | ||
88 | 4: 12, | ||
89 | 5: 14, | ||
90 | 6: 16, | ||
91 | 7: "reserved", | ||
92 | } | ||
93 | |||
94 | DIGITAL_INTERFACES = { | ||
95 | 0: "undefined", | ||
96 | 2: "HMDIa", | ||
97 | 3: "HDMIb", | ||
98 | 4: "MDDI", | ||
99 | 5: "DisplayPort", | ||
100 | } | ||
101 | |||
102 | IMAGE_ASPECT_RATIOS = { | ||
103 | 0: "16:10", | ||
104 | 1: "4:3", | ||
105 | 2: "5:4", | ||
106 | 3: "16:9", | ||
107 | } | ||
108 | |||
109 | class EDID: | ||
110 | |||
111 | manufacturer_id = None | ||
112 | product_code = None | ||
113 | serial_number = None | ||
114 | mfg_week = None | ||
115 | mfg_year = None | ||
116 | edid_version = None | ||
117 | edid_revision = None | ||
118 | interface_is_digital = None | ||
119 | iterface_type = None | ||
120 | bit_depth = None | ||
121 | gamma = None | ||
122 | resolutions = None | ||
123 | x_resolution = None | ||
124 | vertical_freqency = None | ||
125 | image_aspect_ratio = None | ||
126 | timing_descriptors = None | ||
127 | display_descriptors = None | ||
128 | number_of_extensions = None | ||
129 | checksum = None | ||
130 | |||
131 | # These are just here for convenience | ||
132 | display_name = None | ||
133 | mfg_serial_number = None | ||
134 | other_mfg_text = None | ||
135 | |||
136 | # DELL XPS 13 laptops have broken EDID so this tries to still give the | ||
137 | # display a reasonable name. This might need to be more clever. | ||
138 | @property | ||
139 | def reasonable_name(self): | ||
140 | if self.display_name: | ||
141 | return self.display_name | ||
34 | else: | 142 | else: |
35 | return None | 143 | return "{}_{}".format(self.manufacturer_id, self.product_code) |
36 | 144 | ||
145 | def __init__(self, **kwargs): | ||
146 | for k, v in kwargs.items(): | ||
147 | if hasattr(self, k): | ||
148 | setattr(self, k, v) | ||
37 | 149 | ||
38 | def get_connected_displays(): | 150 | def __repr__(self): |
39 | out = subprocess.check_output(["xrandr", "--verbose", "--query"]) | 151 | attrs = [] |
152 | for k, v in self.__dict__.items(): | ||
153 | attrs.append("{}={!r}".format(k, v)) | ||
40 | 154 | ||
41 | current_display = None | 155 | return "{}({})".format(self.__class__.__name__, ", ".join(attrs)) |
42 | in_edid = False | ||
43 | current_edid = [] | ||
44 | 156 | ||
45 | all_edids = {} | ||
46 | 157 | ||
47 | for line in out.decode("us-ascii").split("\n"): | 158 | class DisplayDescriptors(defaultdict): |
48 | if ' connected ' in line: | ||
49 | current_display = line.split(" ")[0] | ||
50 | 159 | ||
51 | if current_display and 'EDID:' in line: | 160 | def get_first(self, key, default=None): |
52 | in_edid = True | 161 | return self.get(key, [default])[0] |
53 | continue | 162 | |
163 | def __repr__(self): | ||
164 | return repr(dict(self)) | ||
165 | |||
166 | |||
167 | class EDIDParser: | ||
168 | |||
169 | _EDID_MAGIC = (0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00) | ||
170 | |||
171 | @staticmethod | ||
172 | def _decode_manufacturer_id(id): | ||
173 | out = "" | ||
174 | |||
175 | packed = id[0] << 8 | id[1] # Big endian | ||
176 | out += chr((packed >> 10 & 0x1f) + 64) | ||
177 | out += chr((packed >> 5 & 0x1f) + 64) | ||
178 | out += chr((packed & 0x1f) + 64) | ||
179 | chks = packed >> 11 & 0x1f # TODO: chks should be 0 | ||
180 | |||
181 | return out | ||
182 | |||
183 | @staticmethod | ||
184 | def _unpack_16bit_le_int(bytes): | ||
185 | v = struct.unpack("<2B", bytes) | ||
186 | return v[0] << 8 | v[1] | ||
187 | |||
188 | @staticmethod | ||
189 | def _parse_descriptors(bytes): | ||
190 | timing_descriptors, display_descriptors = [], DisplayDescriptors(list) | ||
191 | |||
192 | for i in range(54, 126, 18): | ||
193 | d = bytes[i:i+18] | ||
194 | |||
195 | # Probably a Display Descriptor | ||
196 | if d[0:3] == b'\x00\x00\x00' and d[4] == 0x00: | ||
197 | type, data = d[3], d[5:] | ||
198 | |||
199 | if type in DISPLAY_DESCRIPTOR_ASCII_TYPES: | ||
200 | type_name = DISPLAY_DESCRIPTOR_ASCII_TYPES[type] | ||
201 | display_descriptors[type_name].append( | ||
202 | data.decode("us-ascii", "backslashreplace").strip()) | ||
203 | elif type in DISPLAY_DESCRIPTOR_TYPES: | ||
204 | type_name = DISPLAY_DESCRIPTOR_TYPES[type] | ||
205 | display_descriptors[type_name].append(data) | ||
206 | else: | ||
207 | # TODO: Parse descriptors | ||
208 | timing_descriptors.append(d) | ||
209 | |||
210 | return timing_descriptors, display_descriptors | ||
211 | |||
212 | @staticmethod | ||
213 | def _parse_standard_resolutions(bytes): | ||
214 | resolutions = [] | ||
215 | |||
216 | stdres = (bytes[0] << 16) | (bytes[1] << 8) | bytes[2] | ||
217 | for k, v in ESTABLISHED_TIMING.items(): | ||
218 | if stdres & k != 0: | ||
219 | resolutions.append(v) | ||
220 | |||
221 | return resolutions | ||
222 | |||
223 | # Expects binary EDID blob, not hex encoded | ||
224 | def parse(self, edid): | ||
225 | if edid == b"": | ||
226 | return | ||
227 | |||
228 | magic = struct.unpack("8B", edid[:8]) | ||
229 | if magic != self._EDID_MAGIC: | ||
230 | raise Exception("Invalid magic number") | ||
231 | |||
232 | manufacturer_id = self._decode_manufacturer_id( | ||
233 | struct.unpack(">2B", edid[8:10])) | ||
234 | |||
235 | mfg_product_code = self._unpack_16bit_le_int(edid[10:12]) | ||
236 | serial_number = struct.unpack("<i", edid[12:16])[0] | ||
54 | 237 | ||
55 | if in_edid and line[:2] == "\t\t": | 238 | mfg_week = edid[16] # Numbers not standardized |
56 | current_edid.append(line.strip()) | 239 | mfg_year = 1990 + edid[17] |
57 | elif in_edid and line[:2] != "\t\t": | 240 | edid_version = edid[18] # 01 for 1.3 and 1.4 |
58 | all_edids[current_display] = parse_edid(current_edid) | 241 | edid_rev = edid[19] # 03 for 1.3 or 04 for 1.4 |
59 | in_edid = False | ||
60 | current_display = None | ||
61 | current_edid = [] | ||
62 | 242 | ||
63 | return all_edids | 243 | is_digital = edid[20] & 0b10000000 == 128 |
244 | interface = None | ||
245 | bit_depth = None | ||
64 | 246 | ||
247 | if is_digital: | ||
248 | interface = edid[20] & 0b00001111 | ||
249 | bit_depth = (edid[20] & 0b01110000) >> 3 | ||
65 | 250 | ||
66 | def main(): | 251 | horizontal_screen_size = edid[21] |
67 | is_xps = is_host_xps() | 252 | vertical_screen_size = edid[22] |
253 | display_interface_type = DIGITAL_INTERFACES[interface] | ||
254 | bit_depth = BIT_DEPTHS[bit_depth] | ||
255 | gamma = edid[23] | ||
68 | 256 | ||
69 | for display, edid in get_connected_displays().items(): | 257 | # TODO: parse edid[24] Features bitmap |
70 | # The EDID data is corrupted on all Dell XPSes | 258 | # TODO: parse edid[25:35] Chromacity Coordinates |
71 | if is_xps and display in {"eDP-1", "eDP1"}: | 259 | |
72 | edid = "DELL_XPS_13" | 260 | resolutions = [] |
261 | resolutions.extend(self._parse_standard_resolutions(edid[35:38])) | ||
262 | |||
263 | x_resolution = None | ||
264 | if edid[38] != 0: | ||
265 | x_resolution = (edid[38] + 31) * 8 | ||
266 | |||
267 | vertical_freq = (edid[39] & 0b00111111) + 60 | ||
268 | image_aspect_ratio = IMAGE_ASPECT_RATIOS[(edid[39] & 0b11000000) >> 6] | ||
269 | |||
270 | timing_descriptors, display_descriptors = self._parse_descriptors(edid) | ||
271 | |||
272 | number_of_extensions = edid[126] | ||
273 | base_checksum = edid[127] | ||
274 | remainder = edid[128:] | ||
275 | |||
276 | # TODO: Parse extensions | ||
277 | #self._parse_extensions(remainder) | ||
278 | |||
279 | return EDID( | ||
280 | manufacturer_id=manufacturer_id, | ||
281 | product_code=mfg_product_code, | ||
282 | serial_number=serial_number, | ||
283 | mfg_week=mfg_week, | ||
284 | mfg_year=mfg_year, | ||
285 | edid_version=edid_version, | ||
286 | edid_revision=edid_rev, | ||
287 | interface_is_digital=is_digital, | ||
288 | iterface_type=interface, | ||
289 | bit_depth=bit_depth, | ||
290 | gamma=gamma, | ||
291 | display_name=display_descriptors.get_first("Display Name"), | ||
292 | mfg_serial_number=display_descriptors.get_first("Serial Number"), | ||
293 | other_mfg_text=display_descriptors.get_first("Unspecified Text"), | ||
294 | resolutions=resolutions, | ||
295 | x_resolution=x_resolution, | ||
296 | vertical_freqency=vertical_freq, | ||
297 | image_aspect_ratio=image_aspect_ratio, | ||
298 | timing_descriptors=timing_descriptors, | ||
299 | display_descriptors=display_descriptors, | ||
300 | number_of_extensions=number_of_extensions, | ||
301 | checksum=base_checksum, | ||
302 | ) | ||
303 | |||
304 | @staticmethod | ||
305 | def _parse_extensions(bytes): | ||
306 | # TODO: Mostly incomplete | ||
307 | # DTD = Detailed Timing Descriptors | ||
308 | ext_tag = bytes[0] | ||
309 | ext_rev = bytes[1] | ||
310 | |||
311 | # We only sort of understand the "CEA EDID Timing Extension" v3 | ||
312 | if ext_tag != 2 or ext_rev != 3: | ||
313 | raise Exception("Unknown extension") | ||
314 | |||
315 | dtd_offset = bytes[2] # 04 means no DTDs, 00 means no data | ||
316 | |||
317 | supports_underscan = bytes[3] & 0b10000000 != 0 | ||
318 | supports_basic_audio = bytes[3] & 0b01000000 != 0 | ||
319 | supports_ycbcr_444 = bytes[3] & 0b00100000 != 0 | ||
320 | supports_ycbcr_422 = bytes[3] & 0b00010000 != 0 | ||
321 | num_dtds_in_native_format = bytes[3] & 0b00001111 | ||
322 | |||
323 | raw_before_dtds = bytes[4:29] | ||
324 | raw_dtds = bytes[29:127] | ||
325 | checksum = bytes[127] | ||
326 | |||
327 | types = { | ||
328 | 1: "audio", | ||
329 | 2: "video", | ||
330 | 3: "vendor", | ||
331 | 4: "speaker allocation", | ||
332 | 5: "VESA DTC", | ||
333 | } | ||
334 | |||
335 | i = 0 | ||
336 | dtds = bytes[4:29] | ||
337 | while i < len(dtds): | ||
338 | header = dtds[i] | ||
339 | i += 1 | ||
340 | type = (header & 0b11100000) >> 5 | ||
341 | length = header & 0b00011111 | ||
342 | body = dtds[i:i+length+1] | ||
343 | i += length | ||
344 | print(type, types.get(type), length, body) | ||
345 | |||
346 | # DTDs are 18-byte packets the same as the main EDID | ||
347 | # They are padded to 128 bytes with \x00 | ||
348 | # Thus len(raw_dtds) % 18 is padding length | ||
349 | |||
350 | return None | ||
351 | |||
352 | |||
353 | def get_status(dev_path): | ||
354 | status_path = os.path.join(dev_path, "status") | ||
355 | |||
356 | if os.path.exists(status_path): | ||
357 | with open(status_path, "r") as fp: | ||
358 | return fp.read().strip() | ||
359 | |||
360 | return "unknown" | ||
361 | |||
362 | |||
363 | def read_edid_sysfs(dev_path): | ||
364 | edid_path = os.path.join(dev_path, "edid") | ||
365 | if not os.path.exists(edid_path): | ||
366 | return None | ||
367 | |||
368 | with open(edid_path, "rb") as fp: | ||
369 | return EDIDParser().parse(fp.read()) | ||
370 | |||
371 | |||
372 | def get_connected_edid(): | ||
373 | out = {} | ||
374 | |||
375 | for g in glob.glob("/sys/class/drm/*"): | ||
376 | try: | ||
377 | card, port = os.path.basename(g).split("-", 1) | ||
378 | except ValueError: | ||
379 | continue | ||
73 | 380 | ||
74 | print("{display}:{edid}".format(**locals())) | 381 | if get_status(g) == "connected": |
382 | out[(card, port)] = read_edid_sysfs(g) | ||
75 | 383 | ||
76 | return 0 | 384 | return out |
77 | 385 | ||
78 | 386 | ||
79 | if __name__ == "__main__": | 387 | if __name__ == "__main__": |
80 | sys.exit(main()) | 388 | for (card, display), edid in get_connected_edid().items(): |
389 | print("{}:{}".format(display, edid.reasonable_name)) | ||