summaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
authorMike Crute <mike@crute.us>2020-11-25 11:05:04 -0800
committerMike Crute <mike@crute.us>2020-11-25 11:05:04 -0800
commit544696cafd4a904c76d8b528f43097928e8a2388 (patch)
tree9d3703d7c95d980845990078f2ce16ab7ec83201 /bin
parentb95fe58226e36dadd0602b0ddd35194b4ba02169 (diff)
downloaddotfiles-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-xbin/enumerate-displays415
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 3Extended Display Identification Data Parser (mostly incomplete)
4# 4by Mike Crute (mike@crute.us)
5licensed under the terms of the MIT license
5 6
6import re 7This is basically just a minimum viable implementation of an EDID parser. There
7import sys 8is a weird subset of features supported because I got excited about parsing
8import subprocess 9this wacky binary structure. But there's still a lot left un-implemented
10because I ran out of time and had already satisfied my use-case.
9 11
12If you use this and need or want to implement the rest I'll gladly take pull
13requests, see the TODOs below.
10 14
11def is_host_xps(): 15To use it just call `get_connected_edid` and consider the `EDID` classes
12 try: 16returned.
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)) 18Based 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"""
22import os
23import glob
24import struct
25from collections import defaultdict
19 26
20 27
21def parse_edid(hex_list): 28ESTABLISHED_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))) 48DISPLAY_DESCRIPTOR_ASCII_TYPES = {
49 0xFF: "Serial Number",
50 0xFE: "Unspecified Text",
51 0xFC: "Display Name",
52}
27 53
28 identifier = re.findall( 54DISPLAY_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: 83BIT_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
94DIGITAL_INTERFACES = {
95 0: "undefined",
96 2: "HMDIa",
97 3: "HDMIb",
98 4: "MDDI",
99 5: "DisplayPort",
100}
101
102IMAGE_ASPECT_RATIOS = {
103 0: "16:10",
104 1: "4:3",
105 2: "5:4",
106 3: "16:9",
107}
108
109class 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
38def 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"): 158class 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
167class 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
66def 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
353def 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
363def 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
372def 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
79if __name__ == "__main__": 387if __name__ == "__main__":
80 sys.exit(main()) 388 for (card, display), edid in get_connected_edid().items():
389 print("{}:{}".format(display, edid.reasonable_name))