Features Automirror Screen Mirroring Lead image: Lead Image © bowie15, 123RF.com
Lead Image © bowie15, 123RF.com
 

Using a Bash script to mirror external monitors

Mirror, Mirror

If you use your Linux laptop for public presentations – or other tasks that require an external display – you are probably familiar with the problem of making your computer's display resolution fit the external device. This homegrown script offers an easy, automated solution. By Schlomo Schapiro

Like many of my colleagues, I use my own laptop to play back presentations at conferences. My Dell Latitude E6430 works perfectly on Ubuntu. However, one critical problem remains: when I connect the device to a projector or a different display, I don't always get the aspect ratio I would prefer. Screen mirroring typically only gives a poor resolution of 1,024x768 pixels, with large black boxes left and right. Unfortunately, my laptop has a 16:9 display with a resolution of 1,600x900 pixels.

Common Denominator

Some research on the topic of screen resolution reveals the root cause: the maximum resolution that my laptop display and the external projector or monitor have in common is 1,024x768 pixels. All higher resolutions are only available on one of the two devices. When mirroring, my Ubuntu system thus automatically chooses the 1,024x768 resolution as the best common option between the two display devices [1].

Because the external monitor typically uses a 16:10 aspect ratio as a normal office display, it only offers 16:10 resolutions via the data display channel (DDC) interface [2]. In addition to its native 16:9 resolution, the laptop display also supports a number of lower resolutions, but many of these resolutions are rarely supported by external displays or projectors.

In many situations, as the speaker, I want the external display or projector to mirror my laptop's screen contents, so I can work on my laptop while letting others watch.

Enter xrandr

Working on my laptop with a resolution of 1,024x768 is not much fun, but luckily, I have another option. The X Server RandR extension (xrandr) "provides automatic discovery of modes (resolutions, refresh rates, …) and the ability to configure output dynamically (resize, rotate, move, …)" [3]. The xrandr configuration tool lets me configure settings for RandR [4] in Linux. The call to xrandr is pretty bulky:

xrandr --fb 1600x900 --output LVDS1 --mode 1600x900 \
  --scale 1x1 --output HDMI3 --same-as LVDS1 --mode 1920x1200 \
  --scale-from 1600x900

This command mirrors my laptop screen on the external display and scales the image from the native 1,600x900 resolution on my laptop to a native 1,900x1,200 resolution on the external display.

Although the external display extends the vertical dimension of the image, viewers are unlikely to notice the change. With today's crop of 16:9 projectors, this trick works even better, whether the projector works with 720, 768, or 1,080 lines, the results are usually successful thanks to xrandr scaling.

Automated Happiness

After a few months of experimenting, and an increasing number of xrandr command lines, I started investigating how to automate the whole process. Unfortunately, the display settings in Ubuntu do not currently support the new scaling options in xrandr 1.3. Although you could retroactively install several additional tools for screen settings, all they do – in the best case – is manage different profiles. At this writing, none of these tools automatically configures the best possible mirroring configuration.

Armed with some knowledge of Bash and sed, you can solve this problem, however. Listing 1 shows a complete script called automirror.sh [5]; The script is GPL'd and needs the xrandr tool and notify-send as dependencies on Ubuntu. (Both xrandr and notify-send are part of the default Ubuntu installation, but they are also available on other Linux distributions.) You can copy the script to ~/bin or /usr/bin.

Listing 1: automirror.sh

01 #!/bin/bash
02 set -e -E -u
03
04 XRANDR_STATUS_PROGRAM=${XRANDR_STATUS_PROGRAM:-xrandr}
05 XRANDR_SET_PROGRAM=${XRANDR_SET_PROGRAM: -xrandr}
06
07 PRIMARY_DISPLAY=${AUTOMIRROR_PRIMARY_DISPLAY:-LVDS1}
08 NOTIFY_SEND=( ${AUTOMIRROR_NOTIFY_COMMAND: -notify-send -a automirror \
                -i automirror "Automatic Mirror Configuration"} )
09
10 # force called programs to english output
11 LANG=C LC_ALL=C
12
13 function die {
14     echo 1>&2 "$*"
15     exit 10
16 }
17
18 function get_display_resolution {
19     local find_display="$1" ; shift
20     local display_list="$1"
21     while read display width_mm height_mm width height ; do
22         if [[ "$display" == "$find_display" ]] ; then
23             echo ${width}x${height}
24             return 0
25         fi
26     done <<<"$display_list"
27     die "Could not determine resolution for '$find_display'. Display Data:
28 $display_list"
29 }
30
31 function get_highest_display {
32     local display_list="$1" ; shift
33     local data=( $(sort -r -n -k 5 <<<"$display_list") )
34     echo $data
35 }
36
37 xrandr_current="$($XRANDR_STATUS_PROGRAM)"
38
39 # find connected displays by filtering those that are \
    connected and have a size set in millimeters (mm)
40 connected_displays=( $(sed -n -e 's/^\(.*\) connected.\
                          *mm$/\1/p' <<<"$xrandr_current") )
41
42 # See http://stackoverflow.com/a/1252191/2042547 \
    for how to use sed to replace newlines
43 # display_list is a list of displays with their \
    maximum/optimum pixel and physical dimensions
44 # thanks to the first sed I know that here is only a SINGLE space
45 display_list="$(sed ':a;N;$!ba;s/\n   / /g'<<<"$xrandr_current" | sed \
    -n -e 's/^\([a-zA-Z0-9_-]\+\) connected.* \([0-9]\+\)mm.* \([0-9]\+\)mm \
    \([0-9]\+\)x\([0-9]\+\).*$/\1 \2 \3 \4 \5/p' )"
46 : connected_displays: ${connected_displays[@]}
47 : display_list: "$display_list"
48
49 if [[ -z "$display_list" ]] ; then
50     die "Could not find any displays connected. XRANDR output:
51 $xrandr_current"
52 fi
53
54 # if the primary display is NOT connected then use the highest \
    display as primary
55 if [[ "${connected_displays[*]}" != *$PRIMARY_DISPLAY* ]] ; then
56     PRIMARY_DISPLAY=$(get_highest_display "$display_list")
57 fi
58 frame_buffer_resolution=$(get_display_resolution \
                             $PRIMARY_DISPLAY "$display_list")
59
60 : $frame_buffer_resolution
61
62 xrandr_set_args=( --fb $frame_buffer_resolution )
63 notify_string=""
64 if (( ${#connected_displays[@]} == 1 )) ; then
65     xrandr_set_args+=( --output $connected_displays --mode \
                         $frame_buffer_resolution --scale 1x1 )
66     notify_string="$connected_displays reset to $frame_buffer_resolution"
67 else
68     other_display_list="$(grep -v ^$PRIMARY_DISPLAY <<<"$display_list")"
69     $XRANDR_SET_PROGRAM $(while read display junk ; do echo " --output \
                             $display --scale 1x1 --off" ; done \
                             <<<"$other_display_list")
70     xrandr_set_args+=( --output $PRIMARY_DISPLAY --mode \
                         $frame_buffer_resolution --scale 1x1 )
71     notify_string="$PRIMARY_DISPLAY is primary at $frame_buffer_resolution"
72     while read display junk ; do
73         mode="$(get_display_resolution $display "$other_display_list")"
74         xrandr_set_args+=( --output $display --same-as \
                             $PRIMARY_DISPLAY --mode "$mode" --scale-from \
                             $frame_buffer_resolution  )
75         notify_string="$notify_string\n$display is scaled mirror at $mode"
76     done <<<"$other_display_list"
77 fi
78
79 #logger -s -t "$0" -- Running $XRANDR_SET_PROGRAM "${xrandr_set_args[@]}"
80
81 $XRANDR_SET_PROGRAM "${xrandr_set_args[@]}"
82 ret=$?
83 "${NOTIFY_SEND[@]}" "$notify_string"
84 exit $ret

Detection Service

The workflow in the automirror.sh script is simple and emulates the human decision-making path. Lines 4 to 8 start by setting global environmental variables. You can easily use variables to replace the external programs if you need to design tests. The script uses the connected_displays variable (line 40) to save the active screen output and its resolution in millimeters; display_list (line 45) defines the best possible resolution to the current screen output and its height in lines. Xrandr sorts the list of resolutions so that the first resolution is typically also the recommended resolution. The ugly sed expression saves me many lines of Bash, and I hope you will excuse its use.

Lines 55 through 57 define a primary display: The primary display is either the laptop screen or the screen with the largest number of lines.

If the primary display is not attached to the LVDS1 connection, as assumed in line 7, you can use the PRIMARY_DISPLAY environmental variable to set up different connection. The script then defines the virtual display resolution for the frame buffer (line 65) for this display; the remaining displays end up in the other_display_list (line 68).

The while loop in line 72 generates the xrandr command lines with the native display resolutions and determines the modes via the get_display_resolution() function in that starts in line 18. Line 74 then calibrates the scaling of the PRIMARY_DISPLAY with that of the other displays. Line 75 generates a string for desktop messaging from the determined resolutions. In line 81, xrandr configures all the displays, and notify-send in line 83 informs the user of the resolution decisions (Figure 1 ).

The Automirror script uses notify-send to inform the desktop user about changes to the screen resolution.
Figure 1: The Automirror script uses notify-send to inform the desktop user about changes to the screen resolution.

Test Operation

I continually stumble across new combinations of internal and external display devices that force me to modify the script. To avoid inadvertently destroying the working scenario, the GitHub project for Automirror [5] contains unit tests for all scenarios.

The runtests.sh script in Listing 2 iterates in lines 19 through 32 across the test cases in the testdata/ directory, running the do_test function from lines 4 to 15 for each test case. A test case consists of a line of text with the output from xrandr for a specific configuration, along with the response file (*_result.txt) with the anticipated xrandr calls.

Listing 2: runtests.sh

01 #!/bin/bash
02 set -e -E -u
03
04 function do_test {
05     local xrandr_output="$1" ; shift
06     local script_output="$1" ; shift
07     res="$( XRANDR_STATUS_PROGRAM="cat $xrandr_output" \
              XRANDR_SET_PROGRAM=echo AUTOMIRROR_NOTIFY_COMMAND=: \
              ./automirror.sh)"
08     if [[ "$res" == "$(< $script_output)" ]] ; then
09         return 0
10     else
11         echo "Difference:"
12         diff -u --label EXPECTED_RESULT --label ACTUAL_RESULT -- \
             $script_output - <<<"$res"
13         return 1
14     fi
15 }
16
17 failed=0
18
19 for test_case in testdata/*.txt ; do
20     [[ "$test_case" == *result.txt ]] && continue
21     script_output=${test_case%.txt}_result.txt
22     if [[ "$script_output" && -r "$script_output" ]] ; then
23         if do_test "$test_case" "$script_output" ; then
24            echo "OK $test_case"
25         else
26             let failed++ 1
27             echo "FAILED $test_case"
28         fi
29     else
30         echo "Missing test result file '$script_output'"
31     fi
32 done
33
34 if (( failed == 0 )) ; then
35     exit 0
36 else
37     echo $failed TESTS FAILED
38     exit 1
39 fi

For the test run, cat and echo in line 7 use the environmental variables XRANDR_STATUS_PROGRAM and XRANDR_SET_PROGRAM to replace the xrandr and notify-send program. The automirror.sh script then needs to work in all test cases; otherwise, runtests.sh shows the failed test and the anticipated result.

Packaged

If the script passes all the tests, the makefile builds a matching Debian package for installing [6]. The matching Debian changelog and the version are automatically generated from the Git commits by the git-dch tool.

To support a new configuration, I first design a new test case, then modify the script until the old and new tests all work. The commands git commit -a and make release deb produce a new Debian package, which is immediately sent to GitHub's software repository.