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:

layout

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:

desktop setup

And it would have been working fine, if:

  1. the (xrandr) output names stayed reasonably stable over time
  2. 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:

  1. sometimes one of the displays refuses to wake up / configure properly (frantically cycling between “no signal” and sleep)
  2. sometimes the displays end up swapped
  3. sometimes one (or more) displays don’t get configured
  4. 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 arandr4, 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:

layout

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:

  1. Parse xrandr --prop
  2. Get config that assigns serial (really a substring from EDID) to xrandr output config
  3. 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.

  1. This is like sticking your head out shouting: heisenbugs, come and get me.

  2. apt install autorandr

  3. Having a random distro forced on you is fun. You should try it sometime.

  4. Not to be confused with autorandr. It’s a visual frontend for xrandr. apt install arandr.

  5. apt install ddcutil, and you want this thing – for example for setting the display brightness (ddcutil -b 1 setvcp 10 50), etc.

  6. https://github.com/wejn/xrandr-by-edid for the repo.

  7. 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'.