Over-Engineering a Display Toggle for Great Good

After dealing with a touchbar MacBook Pro at work I finally made the decision to buy a personal machine that didn't include Apple hardware. That also meant going home to Linux1 after a few years hiatus. In the intervening years since I last tried Linux systemd firmly supplanted other init systems, Unity came and went on Ubuntu-family distributions, and in general, the Linux userland experience became quite a bit more polished.

Despite all these advances in the Linux userland, however, there are still some rough edges compared to non-free alternatives like macOS2 and Windows. If one doesn't want to run a full-blown desktop environment like KDE or GNOME, the landscape for tools that manage displays gets low-level fairly quickly. The defacto tool appears to be xrandr along with a collection of homebrew scripts for managing one's displays. While this works for most people, I want there to be a little more Just Works™ magic in my tooling.

The following misadventure details how I've gone from a collection of homebrew scripts using xrandr to a full blown Haskell tool that's invoked via udev rules to manage my displays, affectionately called togglemon.

Stating Our Intent Clearly

To be fair, the xrandr approach works well in most cases, but it's laborious typing out a script name every time I want to plug in an external monitor when other operating systems or desktop environments just detect and extend displays with some default configuration3. So let's clearly state what I'm after. In doing so at this early stage I'm able to give better definition to the solution I'm looking for. The clearer I am about intent, the easier it is to define intermediate milestones and prevent scope creep that can kill any motivation I have to continue working past the first day.

I would like a tool that allows me to plug in a display, expect that it is set as the active display, and disables my laptop screen. When I unplug the monitor, I expect this behavior to be reversed, preserving the configuration I had before. This behavior should work the same irrespective of the port the monitor is plugged into.

It's really the last sentence that gets me into trouble. The scripts I've hastily thrown together are hardcoded to specific ports on my system. xrandr likes to name displays things like DP1, roughly corresponding to the port that your graphics card configures for your system4. If you change the port that you plug the monitor into, say DP2 instead of DP1, my script no longer works.

Could I parse the output of xrandr in a shell script? Sure, but after taking a quick look at the output I decided I'd rather drink battery acid. See for yourself:

$> xrandr
Screen 0: minimum 8 x 8, current 3840 x 2160, maximum 32767 x 32767
eDP1 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis) 290mm x 170mm
   3840x2160     60.00*+  59.97    48.00  
   3200x1800     59.96    60.00    59.94  
   2880x1620     60.00    59.96    59.97  
   2560x1600     59.99    59.97  
   2560x1440     59.96    60.00    59.95  
   2048x1536     60.00  
   1920x1440     60.00  
   1856x1392     60.01  
   1792x1344     60.01  
   2048x1152     60.00    59.90    59.91  
   1920x1200     59.88    59.95  
   1920x1080     59.96    60.00    59.93  
   1600x1200     60.00  
   1680x1050     59.95    59.88  
   1400x1050     59.98  
   1600x900      60.00    59.95    59.82  
   1280x1024     60.02  
   1400x900      59.96    59.88  
   1280x960      60.00  
   1368x768      60.00    59.88    59.85  
   1280x800      59.81    59.91  
   1280x720      59.86    60.00    59.74  
   1024x768      60.00  
   1024x576      60.00    59.90    59.82  
   960x540       60.00    59.63    59.82  
   800x600       60.32    56.25  
   864x486       60.00    59.92    59.57  
   640x480       59.94  
   720x405       59.51    60.00    58.99  
   640x360       59.84    59.32    60.00  
DP1 disconnected (normal left inverted right x axis y axis)
DP2 disconnected (normal left inverted right x axis y axis)
VIRTUAL1 disconnected (normal left inverted right x axis y axis)

An experienced shell wizard might look at this output and come up with a clever one-liner to parse this mess. Dear reader, if that shell wizard is you, I would be most interested in your solution. In the absence of your solution, however, I decided to pursue another tack. Linux typically documents much of it's resources as files on the filesystem, if you only know where to look...

Everything is a File

It turns out that sysfs documents kernel resources that are available pretty well, including one particular kernel sub-system called the Direct Rendering Manager, or DRM for short5. This means that /sys/class/ on my filesystem lists a bunch of kernel resources, drm among them. Here's mine:

$> ls /sys/class/drm
card0
card0-DP-1
card0-DP-2
card0-eDP-1
renderD128
version

So what are we looking at here? DRM exposes information about monitors according to what graphics card it's associated with. This machine in particular only has a single graphics card, so all peripheral ports are labeled with a card0 prefix. The next three entries are the available ports on my system, with eDP appearing to be the internal display on the laptop itself. I don't know much more about how DRM goes about naming these available ports, though I suspect it is largely left up to the graphics card manufacturer. The astute observer will note that the same display names we use in our xrandr command is in the middle segment of each directory name.

For the purposes of our investigation, renderD128 and version don't contain any relevant information that we would need to manage our displays, though if you're interested in rabbit-holing on this one point you can read up on DRM or sysfs rules in more detail.

Now let's explore what one of these display ports contains:

$> ls -Ahltr /sys/class/drm/card0-eDP-1/
total 0
-rw-r--r-- 1 root root 4.0K Aug  2 16:53 uevent
lrwxrwxrwx 1 root root    0 Aug  2 16:53 subsystem -> ../../../../../../class/drm
drwxr-xr-x 3 root root    0 Aug  2 16:53 intel_backlight
drwxr-xr-x 3 root root    0 Aug  2 16:53 i2c-5
drwxr-xr-x 3 root root    0 Aug  2 16:53 drm_dp_aux0
drwxr-xr-x 2 root root    0 Aug  2 16:53 power
-r--r--r-- 1 root root 4.0K Aug  2 16:53 enabled
-r--r--r-- 1 root root 4.0K Aug  2 16:53 dpms
-rw-r--r-- 1 root root 4.0K Aug  4 08:16 status
-r--r--r-- 1 root root 4.0K Aug  4 08:35 modes
-r--r--r-- 1 root root    0 Aug  4 08:35 edid
lrwxrwxrwx 1 root root    0 Aug  4 08:35 device -> ../../card0

Inside of each display port we'll find a number of pieces of interesting information6. cat-ing these files tells us various things about the monitor that is currently plugged in. status and enabled will be most useful for our purposes today, but feel free to explore what some of these other files and directories contain (modes might be useful in configuring a certain resolution for your display, for example).

The status and enabled files contain information about whether a display is currently connected and currently in use respectively. status values can either be disabled or enabled, while enabled files can either be connected or disconnected. With these two pieces of information we should be able to reliably determine which monitor is currently plugged in and in use, and which monitor is plugged in just waiting for instructions.

We now have enough information to start designing a simple solution to our problem.

Modeling a Solution to our Problem

Here's a summary of the information we have available to us:

  • We want to be able to automatically toggle displays when we plug in a monitor, regardless of the port it's plugged into
  • Information about displays live in /sys/class/drm, prefixed by the graphics card that owns the port
  • Individual display ports contain information about whether a monitor is connected and enabled
  • This information can be mapped to equivalent xrandr commands

While we're still a ways off from using udev to automatically toggle monitors, it's likely a safe bet that an invokable command line tool is the winning strategy here.

With that in mind, here's a first pass at an algorithm to solve the problem:

  1. List the contents of /sys/class/drm
  • Filter contents to just directories
  1. For each directory, read the contents of the status and enabled files
  • If these files don't exist, do nothing as we do not have a monitor7
  1. Construct an active / passive display configuration based on connected monitors
  • A monitor is active if it is enabled
  • A monitor is passive if it is disabled
  1. Construct an xrandr command based on the active / passive display configuration
  2. Execute the xrandr command

Let's walk through an example of how this would work, using the following diagram as a guide:

We start by reading the contents of /sys/class/drm and find two displays:

  • Our internal laptop display, labeled eDP1
  • Our external display, labeled DP2

We then read the status and enabled files and find that we're currently using our laptop screen, but our external display is currently connected. In other words, our laptop display is Active and our external display is Passive, so we construct a configuration that says as much.

Then, we use that configuration to construct a valid xrandr command that will toggle the displays for us and finally execute it.

By breaking up the problem in this way we can visually see that there are some objects, or types, we can define to model the problem space neatly. Each block in the diagram might be a function that transitions from one type of data to another. This strategy is common when designing programs that follow functional programming principles: define types that model your problem space, then define functions that form the transitions between each state. If you're skeptical of this decision I'll refer you to the litany of long dissertations about the benefits of functional programming, so that we avoid turning this post into another such dissertation.

In the next post, we'll begin implementing our solution using my functional language of choice: Haskell. We'll see how Haskell makes it straightforward for us to model the problem space, define functions to get us from one state to another, and put it all together in a neatly tested package. We'll also start to explore how monad transformers and the [ReaderT pattern] can help us effectively test our application.

Until next time, and happy hacking!

Footnotes

  1. I'd just like to interject for a moment. What you're referring to as Linux, is in fact, GNU/Linux, or as I've recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. Many computer users run a modified version of the GNU system every day, without realizing it. Through a peculiar turn of events, the version of GNU which is widely used today is often called "Linux", and many of its users are not aware that it is basically the GNU system, developed by the GNU Project. There really is a Linux, and these people are using it, but it is just a part of the system they use. Linux is the kernel: the program in the system that allocates the machine's resources to the other programs that you run. The kernel is an essential part of an operating system, but useless by itself; it can only function in the context of a complete operating system. Linux is normally used in combination with the GNU operating system: the whole system is basically GNU with Linux added, or GNU/Linux. All the so-called "Linux" distributions are really distributions of GNU/Linux.

  2. That said, the fact that macOS, as polished as it can be at times, still can't remember my display configuration when I plug my monitors back in is frustrating. [hurd]: https://www.gnu.org/software/hurd/hurd.html

  3. <insert crocodile tears here>

  4. If you're already familiar with DRM you may recognize there's a bit more nuance to it than this, though.

  5. No, not that kind of DRM.

  6. It should be noted, however, that the information within these directories is not guaranteed to be consistent. That is, if I plug in an external monitor that doesn't expose backlight functionality, I shouldn't expect to see an intel_backlight file in the tree.

  7. This'll become useful for filtering directories like card0, that represent the graphics card and not the display.