Setting Xorg screen layout by display serial numbers
Problem statement
No matter what computing device I happen to be using, I would like to have my X11 / Xorg screen layout look like this, thank you very much:
That used to be easy in the past. I used to connect just one computer – my
desktop – to my displays. So a static xrandr (or even /etc/X11/xorg.conf
)
setup worked fine.
Ever since I got the corporate laptop from hell, this is no longer the case. And I was suffering for that extravaganza.
Nowadays my hardware setup looks like this:
And it would have been working fine, if:
- the (xrandr) output names stayed reasonably stable over time
autorandr
knew how to deal with messy xrandr configuration in the face of changes
Spoiler alert: neither is true.
Probably due to the finicky nature of the DisplayPort KVM coupled with the USB-C to 2x DisplayPort dock, I’m often left with screen layout that’s less than usable:
- sometimes one of the displays refuses to wake up / configure properly (frantically cycling between “no signal” and sleep)
- sometimes the displays end up swapped
- sometimes one (or more) displays don’t get configured
- sometimes (my favorite so far!) the displays end up overlaid on top of one another, with rest of the framebuffer area scrollable with mouse
In this post I’ll explain how I fixed this issue once and for all1 while maintaining one dotfiles config across all the machines.
In other words: How do I get stable screen layout irrespective of what xrandr outputs (or set of displays) I connect my current computing device to.
Cul-de-Sacs (dead-ends)
Before I talk about the final solution, let me take you on a journey of dead ends. Possibly to appease my sense of unease stemming from writing yet another xrandr wrapper.
In the beginning —
Use autorandr2, they said. All will be fine, they said. They lied.
Autorandr is a handy little utility bundled with debian. With a simple promise:
Auto-detect the connected display hardware and load the appropriate X11 setup using xrandr
Stock autorandr across the distros I need to use3 is far from the current
version (1.12.1). In most cases it did not offer the --match-edid
option
required to detect (and deal with) constantly renaming inputs.
To that effect I lived a long time with 5+ profiles: dock-no-builtin
,
dock-no-builtin2
, …, dock-no-builtinN
. Each profile with slightly
different DP-i
names and eDP-1
off.
Once in a while my laptop would surprise me again by a new combo of DP-x
and
DP-y
, so I fired up arandr
4, reconfigured, and reluctantly saved the
new hotness using autorandr --save dock-no-builtinN+1
.
But that gets really old. And doesn’t solve all the problems anyway.
I briefly switched to the latest version of autorandr (adding it to my dotfiles),
to use the --match-edid
feature. I thought that would fix my problems.
It did not – I simply got surprised again.
This time, my outputs would sometimes switch (DP-1
for DP-2
and vice versa),
and some weird little elf living in my machine would overlay the two displays
over one another:
And I mean… I enjoy an occasional debugging session as much as the next engineer. But this was getting tiresome.
An additional trouble is that getting out of this layout isn’t something either
autorandr
or arandr
can do for you. You have to manually break the “panning”.
Standing ovation if you know how off the top of your head.
The key ingredient
Tired of all this shit, I had a realization:
I’m not trying to solve the general problem of configuring arbitrary layouts. My displays are mostly the same. And when they change, I’m happy to manually configure the new line up. Once.
And furthermore, each physical display has a unique serial number5:
# ddcutil detect
Display 1
I2C bus: /dev/i2c-1
EDID synopsis:
Mfg id: DEL
Model: DELL Uxxxxxx
Serial number: Gyyyyyyyyyyy
Manufacture year: 2012
EDID version: 1.4
VCP version: 2.1
Display 2
I2C bus: /dev/i2c-2
EDID synopsis:
Mfg id: DEL
Model: DELL Uxxxxxx
Serial number: Gzzzzzzzzzzz
Manufacture year: 2012
EDID version: 1.4
VCP version: 2.1
What if… I could map the serial number to the xrandr output?!
Turns out, it’s achievable. autorandr
detects the display by the entire
EDID
string. And it doesn’t take much to figure out that it gets the edid from
xrandr
. Because xrandr --prop
or xrandr --verbose
will happily give
you EDID
for each output, even if encoded as hex spanning multiple lines:
$ xrandr --prop | grep EDID: -A 16 | redact-sensitive
EDID:
00ffffffffffff0010ac8040........
25160104a53c22783a4bb5a7564ba325
0a5054a54b008100b300d100714fa940
818001010101565e00a0a0a029503020
350055502100001a000000ff0047....
..................0a000000fc0044
454c4c2055............0a000000fd
0031561d711e010a20202020202001f4
02031df1509005040302071601061112
1513141f2023097f0783010000023a80
1871382d40582c250055502100001e01
1d8018711c1620582c25005550210000
9e011d007251d01e206e285500555021
00001e8c0ad08a20e02d10103e960055
50210000180000000000000000000000
0000000000000000000000000000005d
--
EDID:
00ffffffffffff0010ac8040........
25160104a53c22783a4bb5a7564ba325
0a5054a54b008100b300d100714fa940
818001010101565e00a0a0a029503020
350055502100001a000000ff0047....
...................a000000fc0044
454c4c2055............0a000000fd
0031561d711e010a20202020202001ee
02031df1509005040302071601061112
1513141f2023097f0783010000023a80
1871382d40582c250055502100001e01
1d8018711c1620582c25005550210000
9e011d007251d01e206e285500555021
00001e8c0ad08a20e02d10103e960055
50210000180000000000000000000000
0000000000000000000000000000005d
And running that hex string through xxd
(with redaction):
00000000: 00ff ffff ffff ff00 10ac 8040 .... .... ...........@....
00000010: 2516 0104 a53c 2278 3a4b b5a7 564b a325 %....<"x:K..VK.%
00000020: 0a50 54a5 4b00 8100 b300 d100 714f a940 .PT.K.......qO.@
00000030: 8180 0101 0101 565e 00a0 a0a0 2950 3020 ......V^....)P0
00000040: 3500 5550 2100 001a 0000 00ff 0047 .... 5.UP!........G..
00000050: .... .... .... .... ..0a 0000 00fc 0044 ...............D
00000060: 454c 4c20 55.. .... .... ..0a 0000 00fd ELL U...........
00000070: 0031 561d 711e 010a 2020 2020 2020 01f4 .1V.q... ..
00000080: 0203 1df1 5090 0504 0302 0716 0106 1112 ....P...........
00000090: 1513 141f 2023 097f 0783 0100 0002 3a80 .... #........:.
000000a0: 1871 382d 4058 2c25 0055 5021 0000 1e01 .q8-@X,%.UP!....
000000b0: 1d80 1871 1c16 2058 2c25 0055 5021 0000 ...q.. X,%.UP!..
000000c0: 9e01 1d00 7251 d01e 206e 2855 0055 5021 ....rQ.. n(U.UP!
000000d0: 0000 1e8c 0ad0 8a20 e02d 1010 3e96 0055 ....... .-..>..U
000000e0: 5021 0000 1800 0000 0000 0000 0000 0000 P!..............
000000f0: 0000 0000 0000 0000 0000 0000 0000 005d ...............]
shows the serial number! Yay!
Solution
The plan of action is simple:
- Parse
xrandr --prop
- Get config that assigns serial (really a substring from EDID) to xrandr output config
- Build out a command line for
xrandr
and execute
To that effect, my first take on this is a somewhat heavy Ruby script6, with
minimal dependencies (apt install ruby arandr x11-xserver-utils
):
The xrandr-by-edid.rb script (click to expand)
#!/usr/bin/env ruby
#
# vim: set ts=4 sw=4 et :
# This is meant to setup xrandr layout by edid substring. For example by display
# serial number.
#
# License: GPL v. 2.0, not the latter
require 'pp'
class XRandr
Output = Struct.new(:name, :edid, :screen)
# Parse output of `xrandr --prop` (or supplied input) and return array of Outputs
def self.parse(input = nil)
input ||= `xrandr --prop`
outputs = []
state = :normal
screen = nil
current_output = nil
edid = []
input.split(/\n/).each do |ln|
case state
when :normal
case ln
when /^Screen\s+(\d+):.*/
screen = $1.to_i
when /^(\S*?)\s+((dis|)connected|unknown connection)/
outputs << current_output unless current_output.nil?
current_output = Output.new($1, nil, screen)
when /^\s*EDID:\s*/
state = :edid
edid = []
else
# props... don't care
end
when :edid
if ln =~ /^\s*([0-9a-f]+)\s*$/
edid << $1
else
state = :normal
current_output.edid = edid.join
end
end
end
outputs << current_output unless current_output.nil?
outputs
end
# For raw hex encoded edid, return human readable strings
def self.humanize_edid(edid)
if edid
edid.scan(/../).map { |x|
n=x.to_i(16);
(32..126).include?(n) ? n.chr : "\x0"
}.join.scan(/[\w_-]{3,}/).join(' ')
else
"*** no edid ***"
end
rescue Object
"*** unknown edid (exception when parsing) ***"
end
# Return current xrandr config for given output. Requires unxrandr to work.
def self.current_config(output)
layout = `unxrandr` rescue nil
if layout.nil?
"unknown (need 'unxrandr' installed)"
else
re = Regexp.new("\\s*--output\\s+#{output}\\s+") # nice work, vim ft=ruby
layout = layout.split(/(?=--output)|$/).grep(re)
if layout.size > 0
layout.first.sub(re, '')
else
"unknown (couldn't parse unxrandr)"
end
end
rescue Object
"unknown (exception when parsing)"
end
# Return xrandr config string for given config and (optionally) input
def self.config_string_for(display_configs, default_config = "--off",
must_match_all = false, input = nil)
to_match = display_configs.keys
configs = self.parse(input).map do |output|
edid = output.edid || ""
serial, conf = display_configs.find { |s, _|
edid.index(s.to_s.chars.map { |x| "%02x" % x.ord }.join) }
if serial
to_match.delete(serial)
if $VERBOSE
STDERR.puts "Matched #{output.name} @ #{output.screen} to #{serial}."
end
else
if $VERBOSE
STDERR.puts "Couldn't match #{output.name} to any serial."
STDERR.puts " EDID strings: #{self.humanize_edid(output.edid)}"
STDERR.puts " current config: #{self.current_config(output.name)}"
end
end
["--output", output.name, conf || default_config]
end.flatten
if must_match_all && !to_match.empty?
raise "Failed to match serial(s) '#{to_match.join(' ')}' to an output."
end
configs
end
end
if __FILE__ == $0
require 'optparse'
options = {
configs: {},
default_config: %w[--off],
prefix: %w[],
match_all: false,
}
op = OptionParser.new do |opts|
opts.banner = "Usage: #{File.basename($0)} [options]"
opts.separator 'Available options:'
opts.on("-sSERIAL", "--serial=SERIAL", String,
"Serial of an output for which --config follows") do |s|
options[:serial] = s
end
opts.on("-cCONFIG", "--config=CONFIG", String,
"Config for output with previously specified serial") do |c|
unless options[:serial]
raise OptionParser::InvalidArgument, "no serial given so far"
end
s = options[:serial]
options[:configs][s] ||= c.split(/\s+/)
end
opts.on("-dCONFIG", "--default-config=CONFIG", String,
"Default config for non-matching outputs, by default --off.") do |dc|
options[:default_config] = dc.split(/\s+/)
end
opts.on("-pCONFIG", "--prefix=CONFIG", String,
"Prefix config before any outputs, by default empty.") do |pfx|
options[:prefix] = pfx.split(/\s+/)
end
opts.on("-a", "--[no-]all-or-abort",
"Match all configured or abort, by default false.") do |match_all|
options[:match_all] = match_all
end
opts.on("-v", "--[no-]verbose", "Verbose operation.") do |verbose|
$VERBOSE = verbose
end
opts.on("-n", "--dry-run", "Dry run. Evaluate, don't apply.") do |dry_run|
options[:dry_run] = dry_run
end
end
begin
op.parse!(ARGV)
rescue OptionParser::InvalidOption
STDERR.puts "Error: invalid option: #{$!}"
exit 1
rescue OptionParser::InvalidArgument
STDERR.puts "Error: invalid argument: #{$!.args.join(' ')}"
exit 1
rescue OptionParser::NeedlessArgument
STDERR.puts "Error: needless argument for a bool flag: #{$!.args.join(' ')}"
exit 1
end
if options[:configs].empty? && !options[:dry_run]
STDERR.puts "No config specified, forcing verbose (for debug) and dry" +
" run (to avoid killing your X session)."
options[:dry_run] = true
$VERBOSE = true
end
if $VERBOSE
STDERR.puts "Parsed config:"
PP.pp(options, STDERR)
end
config = nil
begin
config = XRandr.config_string_for(options[:configs],
options[:default_config],
options[:match_all])
rescue
STDERR.puts "Error: #$! (all serials: #{options[:configs].keys.join(', ')})"
exit 1
end
config = options[:prefix] + config
if options[:dry_run]
puts "# dry run mode, would run:"
puts "xrandr #{config.join(' ')}"
exit 0
else
if $VERBOSE
STDERR.puts "Running xrandr with:"
PP.pp(config, STDERR)
end
system("xrandr", *config)
exit $?.exitstatus
end
end
When you run it without parameters, it does the sensible thing of forcing dry run mode (and verbose), so you get something like:
$ ./xrandr-by-edid.rb
No config specified, forcing verbose (for debug) and dry run (to avoid killing your X session).
Parsed config:
{:configs=>{},
:default_config=>["--off"],
:prefix=>[],
:match_all=>false,
:dry_run=>true}
Couldn't match DP-0 to any serial.
EDID strings: L443 Gyyyyyyyyyyy DELL Uxxxxxx q8-
current config: --mode 2560x1440 --pos 1440x825 --rotate normal
Couldn't match DP-1 to any serial.
EDID strings: *** no edid ***
current config: --off
Couldn't match DP-2 to any serial.
EDID strings: L833 Gzzzzzzzzzzz DELL Uxxxxxx q8-
current config: --primary --mode 2560x1440 --pos 0x0 --rotate left
Couldn't match DP-3 to any serial.
EDID strings: *** no edid ***
current config: --off
# dry run mode, would run:
xrandr --output DP-0 --off --output DP-1 --off --output DP-2 --off --output DP-3 --off
and --help
explains the usage:
$ ./xrandr-by-edid.rb --help
Usage: xrandr-by-edid.rb [options]
Available options:
-s, --serial=SERIAL Serial of an output for which --config follows
-c, --config=CONFIG Config for output with previously specified serial
-d, --default-config=CONFIG Default config for non-matching outputs, by default --off.
-p, --prefix=CONFIG Prefix config before any outputs, by default empty.
-a, --[no-]all-or-abort Match all configured or abort, by default false.
-v, --[no-]verbose Verbose operation.
-n, --dry-run Dry run. Evaluate, don't apply.
and from that, one can arrive at a reasonable config:
$ ./xrandr-by-edid.rb \
-a \
-p'--fb 4000x2560' \
-s'Gzzzzzzzzzzz' \
-c'--primary --mode 2560x1440 --rotate left --pos 0x0 --panning 0x0' \
-s'Gyyyyyyyyyyy' \
-c'--mode 2560x1440 --rotate normal --pos 1440x544 --panning 2560x1440+1440+544'
And since I can have more than one setup, I actually take advantage of the -a
flag, and chain the different layouts in a script like so:
#!/bin/bash
xrandr-by-edid.rb -a ...
if [ $? -ne 0 ]; then
# fallback for different set of displays
xrandr-by-edid.rb -a ...
if [ $? -ne 0 ]; then
# another fallback
xrandr-by-edid.rb -a ...
if [ $? -ne 0 ]; then
# last resort (here be sadness and stuff)
xrandr --auto
fi
fi
fi
and then call that script from ~/.xsession
(and/or an Xmonad keyboard hook).
This way no matter what displays I connect to, I always get some reasonable config7.
Perhaps worth explicitly calling out: the --panning
bit of the display config
string (together with proper --fb
) unfuckulates the overlaid display layout
from the example above. And that is what autorandr
/arandr
can’t do for me.
Closing words
Even after coming up with this solution, I’m left thinking that there should be a better – less custom – way to do this.
And if there isn’t some obvious existing solution that I’ve missed, then perhaps
at least a better way to configure the parameters. So the long if
chain can be
avoided.
Pull requests are welcome, but even this is fine, for me, for now.
-
This is like sticking your head out shouting: heisenbugs, come and get me. ↩
-
apt install autorandr
↩ -
Having a random distro forced on you is fun. You should try it sometime. ↩
-
Not to be confused with
autorandr
. It’s a visual frontend for xrandr.apt install arandr
. ↩ -
apt install ddcutil
, and you want this thing – for example for setting the display brightness (ddcutil -b 1 setvcp 10 50
), etc. ↩ -
https://github.com/wejn/xrandr-by-edid for the repo. ↩
-
Almost always. Sometimes I need to force one output off and then reconfigure. But this script is usable even for this case. Exactly how is left as an exercise for the reader. Hint:
-d' ' -s... -c'--off'
. ↩