]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blobdiff - autorandr.py
New upstream version 1.14
[deb_pkgs/autorandr.git] / autorandr.py
index e11628c4959e16e2096aa325e70b3eca55617a0b..93b4e79ca09a13ac621bffa92f6302d508d60bdf 100755 (executable)
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # encoding: utf-8
 #
 # autorandr.py
@@ -26,29 +26,31 @@ from __future__ import print_function
 
 import binascii
 import copy
-import fnmatch
 import getopt
 import hashlib
+import math
 import os
 import posix
 import pwd
 import re
+import shlex
 import subprocess
 import sys
 import shutil
 import time
+import glob
 
 from collections import OrderedDict
-from distutils.version import LooseVersion as Version
 from functools import reduce
 from itertools import chain
 
+
 if sys.version_info.major == 2:
     import ConfigParser as configparser
 else:
     import configparser
 
-__version__ = "1.7"
+__version__ = "1.14"
 
 try:
     input = raw_input
@@ -62,6 +64,22 @@ virtual_profiles = [
     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
+    ("horizontal-reverse", "Stack all connected outputs horizontally at their largest resolution in reverse order", None),
+    ("vertical-reverse", "Stack all connected outputs vertically at their largest resolution in reverse order", None),
+]
+
+properties = [
+    "Colorspace",
+    "max bpc",
+    "aspect ratio",
+    "Broadcast RGB",
+    "audio",
+    "non-desktop",
+    "TearFree",
+    "underscan vborder",
+    "underscan hborder",
+    "underscan",
+    "scaling mode",
 ]
 
 help_text = """
@@ -76,11 +94,15 @@ Usage: autorandr [options]
 --batch                 run autorandr for all users with active X11 sessions
 --current               only list current (active) configuration(s)
 --config                dump your current xrandr setup
+--cycle                 automatically load the next detected profile
 --debug                 enable verbose output
 --detected              only list detected (available) configuration(s)
 --dry-run               don't change anything, only print the xrandr commands
 --fingerprint           fingerprint your current hardware setup
---force                 force (re)loading of a profile
+--ignore-lid            treat outputs as connected even if their lids are closed
+--match-edid            match displays based on edid instead of name
+--force                 force (re)loading of a profile / overwrite exiting files
+--list                  list configurations
 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
                         to skip both in detecting changes and applying a profile
 --version               show version information and exit
@@ -96,6 +118,47 @@ Usage: autorandr [options]
 """.strip()
 
 
+class Version(object):
+    def __init__(self, version):
+        self._version = version
+        self._version_parts = re.split("([0-9]+)", version)
+
+    def __eq__(self, other):
+        return self._version_parts == other._version_parts
+
+    def __lt__(self, other):
+        for my, theirs in zip(self._version_parts, other._version_parts):
+            if my.isnumeric() and theirs.isnumeric():
+                my = int(my)
+                theirs = int(theirs)
+            if my < theirs:
+                return True
+        return len(theirs) > len(my)
+
+    def __ge__(self, other):
+        return not (self < other)
+
+    def __ne__(self, other):
+        return not (self == other)
+
+    def __le__(self, other):
+        return (self < other) or (self == other)
+
+    def __gt__(self, other):
+        return self >= other and not (self == other)
+
+def is_closed_lid(output):
+    if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
+        return False
+    lids = glob.glob("/proc/acpi/button/lid/*/state")
+    if len(lids) == 1:
+        state_file = lids[0]
+        with open(state_file) as f:
+            content = f.read()
+            return "close" in content
+    return False
+
+
 class AutorandrException(Exception):
     def __init__(self, message, original_exception=None, report_bug=False):
         self.message = message
@@ -138,9 +201,14 @@ class AutorandrException(Exception):
 class XrandrOutput(object):
     "Represents an XRandR output"
 
+    XRANDR_PROPERTIES_REGEXP = "|".join(
+        [r"{}:\s*(?P<{}>[\S ]*\S+)"
+         .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
+            for p in properties])
+
     # This regular expression is used to parse an output in `xrandr --verbose'
     XRANDR_OUTPUT_REGEXP = """(?x)
-        ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
+        ^\s*(?P<output>\S[^ ]*)\s+                                                      # Line starts with output name
         (?:                                                                             # Differentiate disconnected and connected
             disconnected |                                                              # in first line
             unknown\ connection |
@@ -160,9 +228,12 @@ class XrandrOutput(object):
         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
         (?:\s*(?:                                                                       # Properties of the output
-            Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
+            Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) |                                 # Gamma value
+            CRTC:\s*(?P<crtc>[0-9]) |                                                   # CRTC value
             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
+                      filter:\s+(?P<filter>bilinear|nearest) |                          # Transformation filter
             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
+            """ + XRANDR_PROPERTIES_REGEXP + """ |                                      # Properties to include in the profile
             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
         ))+
         \s*
@@ -196,7 +267,7 @@ class XrandrOutput(object):
     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
 
     def __repr__(self):
-        return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
+        return "<%s%s %s>" % (self.output, self.fingerprint, " ".join(self.option_vector))
 
     @property
     def short_edid(self):
@@ -213,19 +284,39 @@ class XrandrOutput(object):
         if xrandr_version() >= Version("1.2"):
             options.update(self.XRANDR_12_DEFAULTS)
         options.update(self.options)
+        if "set" in self.ignored_options:
+            options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
         return {a: b for a, b in options.items() if a not in self.ignored_options}
 
     @property
     def filtered_options(self):
         "Return a dictionary of options without ignored options"
-        return {a: b for a, b in self.options.items() if a not in self.ignored_options}
+        options = {a: b for a, b in self.options.items() if a not in self.ignored_options}
+        if "set" in self.ignored_options:
+            options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
+        return options
 
     @property
     def option_vector(self):
         "Return the command line parameters for XRandR for this instance"
         args = ["--output", self.output]
         for option, arg in sorted(self.options_with_defaults.items()):
-            args.append("--%s" % option)
+            if option.startswith("x-prop-"):
+                prop_found = False
+                for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
+                    if prop == option[7:]:
+                        args.append("--set")
+                        args.append(xrandr_prop)
+                        prop_found = True
+                        break
+                if not prop_found:
+                    print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
+                    continue
+            elif option.startswith("x-"):
+                print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
+                continue
+            else:
+                args.append("--%s" % option)
             if arg:
                 args.append(arg)
         return args
@@ -255,19 +346,47 @@ class XrandrOutput(object):
         return x + 10000 * y
 
     def __init__(self, output, edid, options):
-        "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
+        "Instantiate using output name, edid and a dictionary of XRandR command line parameters"
         self.output = output
         self.edid = edid
         self.options = options
         self.ignored_options = []
+        self.parse_serial_from_edid()
         self.remove_default_option_values()
 
+    def parse_serial_from_edid(self):
+        self.serial = None
+        if self.edid:
+            if self.EDID_UNAVAILABLE in self.edid:
+                return
+            if "*" in self.edid:
+                return
+            # Thx to pyedid project, the following code was
+            # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
+            raw = bytes.fromhex(self.edid)
+            # Check EDID header, and checksum
+            if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
+                return
+            serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
+
+            serial_text = None
+            # Offsets of standard timing information descriptors 1-4
+            # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
+            for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
+                if timing_bytes[0:2] == b'\x00\x00':
+                    timing_type = timing_bytes[3]
+                    if timing_type == 0xFF:
+                        buffer = timing_bytes[5:]
+                        buffer = buffer.partition(b'\x0a')[0]
+                        serial_text = buffer.decode('cp437')
+            self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
+
     def set_ignored_options(self, options):
         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
         self.ignored_options = list(options)
 
     def remove_default_option_values(self):
-        "Remove values from the options dictionary that are superflous"
+        "Remove values from the options dictionary that are superfluous"
         if "off" in self.options and len(self.options.keys()) > 1:
             self.options = {"off": None}
             return
@@ -277,7 +396,7 @@ class XrandrOutput(object):
 
     @classmethod
     def from_xrandr_output(cls, xrandr_output):
-        """Instanciate an XrandrOutput from the output of `xrandr --verbose'
+        """Instantiate an XrandrOutput from the output of `xrandr --verbose'
 
         This method also returns a list of modes supported by the output.
         """
@@ -317,7 +436,13 @@ class XrandrOutput(object):
         else:
             edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
 
-        if not match["connected"]:
+        # An output can be disconnected but still have a mode configured. This can only happen
+        # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
+        # output.
+        #
+        # This code needs to be careful not to mix the two. An output should only be configured to
+        # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
+        if not match["width"]:
             options["off"] = None
         else:
             if match["mode_name"]:
@@ -326,10 +451,11 @@ class XrandrOutput(object):
                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
             else:
                 if match["rotate"] not in ("left", "right"):
-                    options["mode"] = "%sx%s" % (match["width"], match["height"])
+                    options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
                 else:
-                    options["mode"] = "%sx%s" % (match["height"], match["width"])
-            options["rotate"] = match["rotate"]
+                    options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
+            if match["rotate"]:
+                options["rotate"] = match["rotate"]
             if match["primary"]:
                 options["primary"] = None
             if match["reflect"] == "X":
@@ -338,7 +464,8 @@ class XrandrOutput(object):
                 options["reflect"] = "y"
             elif match["reflect"] == "X and Y":
                 options["reflect"] = "xy"
-            options["pos"] = "%sx%s" % (match["x"], match["y"])
+            if match["x"] or match["y"]:
+                options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
             if match["panning"]:
                 panning = [match["panning"]]
                 if match["tracking"]:
@@ -355,6 +482,8 @@ class XrandrOutput(object):
                         # I doubt that this special case is actually required.
                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
                               "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
+            if match["filter"]:
+                options["filter"] = match["filter"]
             if match["gamma"]:
                 gamma = match["gamma"].strip()
                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
@@ -362,14 +491,19 @@ class XrandrOutput(object):
                 # so we approximate by 1e-10.
                 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
                 options["gamma"] = gamma
+            if match["crtc"]:
+                options["crtc"] = match["crtc"]
             if match["rate"]:
                 options["rate"] = match["rate"]
+            for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
+                if match[prop]:
+                    options["x-prop-" + prop] = match[prop]
 
         return XrandrOutput(match["output"], edid, options), modes
 
     @classmethod
-    def from_config_file(cls, edid_map, configuration):
-        "Instanciate an XrandrOutput from the contents of a configuration file"
+    def from_config_file(cls, profile, edid_map, configuration):
+        "Instantiate an XrandrOutput from the contents of a configuration file"
         options = {}
         for line in configuration.split("\n"):
             if line:
@@ -389,13 +523,23 @@ class XrandrOutput(object):
             if fuzzy_output in fuzzy_edid_map:
                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
             elif "off" not in options:
-                raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
-                                         "is not off in config file." % (options["output"], options["output"]))
+                raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
+                                         "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
         output = options["output"]
         del options["output"]
 
         return XrandrOutput(output, edid, options)
 
+    @property
+    def fingerprint(self):
+        return str(self.serial) if self.serial else self.short_edid
+
+    def fingerprint_equals(self, other):
+        if self.serial and other.serial:
+           return self.serial == other.serial
+        else:
+           return self.edid_equals(other)
+
     def edid_equals(self, other):
         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
         if self.edid and other.edid:
@@ -404,22 +548,22 @@ class XrandrOutput(object):
             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
             if "*" in self.edid:
-                return fnmatch.fnmatch(other.edid, self.edid)
+                return match_asterisk(self.edid, other.edid) > 0
             elif "*" in other.edid:
-                return fnmatch.fnmatch(self.edid, other.edid)
+                return match_asterisk(other.edid, self.edid) > 0
         return self.edid == other.edid
 
     def __ne__(self, other):
         return not (self == other)
 
     def __eq__(self, other):
-        return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
+        return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
 
     def verbose_diff(self, other):
         "Compare to another XrandrOutput and return a list of human readable differences"
         diffs = []
-        if not self.edid_equals(other):
-            diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
+        if not self.fingerprint_equals(other):
+            diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
         if self.output != other.output:
             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
         if "off" in self.options and "off" not in other.options:
@@ -472,7 +616,10 @@ def debug_regexp(pattern, string):
     return "Debug information would be available if the `regex' module was installed."
 
 
-def parse_xrandr_output():
+def parse_xrandr_output(
+    *,
+    ignore_lid,
+):
     "Parse the output of `xrandr --verbose' into a list of outputs"
     xrandr_output = os.popen("xrandr -q --verbose").read()
     if not xrandr_output:
@@ -481,7 +628,7 @@ def parse_xrandr_output():
     # We are not interested in screens
     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
 
-    # Split at output boundaries and instanciate an XrandrOutput per output
+    # Split at output boundaries and instantiate an XrandrOutput per output
     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
     if len(split_xrandr_output) < 2:
         raise AutorandrException("No output boundaries found", report_bug=True)
@@ -494,6 +641,16 @@ def parse_xrandr_output():
         if output_modes:
             modes[output_name] = output_modes
 
+    # consider a closed lid as disconnected if other outputs are connected
+    if not ignore_lid and sum(
+        o.edid != None
+        for o
+        in outputs.values()
+    ) > 1:
+        for output_name in outputs.keys():
+            if is_closed_lid(output_name):
+                outputs[output_name].edid = None
+
     return outputs, modes
 
 
@@ -513,7 +670,7 @@ def load_profiles(profile_path):
         buffer = []
         for line in chain(open(config_name).readlines(), ["output"]):
             if line[:6] == "output" and buffer:
-                config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
+                config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
                 buffer = [line]
             else:
                 buffer.append(line)
@@ -543,22 +700,76 @@ def get_symlinks(profile_path):
     return symlinks
 
 
+def match_asterisk(pattern, data):
+    """Match data against a pattern
+
+    The difference to fnmatch is that this function only accepts patterns with a single
+    asterisk and that it returns a "closeness" number, which is larger the better the match.
+    Zero indicates no match at all.
+    """
+    if "*" not in pattern:
+        return 1 if pattern == data else 0
+    parts = pattern.split("*")
+    if len(parts) > 2:
+        raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
+    if not data.startswith(parts[0]):
+        return 0
+    if not data.endswith(parts[1]):
+        return 0
+    matched = len(pattern)
+    total = len(data) + 1
+    return matched * 1. / total
+
+
+def update_profiles_edid(profiles, config):
+    fp_map = {}
+    for c in config:
+        if config[c].fingerprint is not None:
+            fp_map[config[c].fingerprint] = c
+
+    for p in profiles:
+        profile_config = profiles[p]["config"]
+
+        for fingerprint in fp_map:
+            for c in list(profile_config.keys()):
+                if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
+                    continue
+
+                print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]), file=sys.stderr)
+
+                tmp_disp = profile_config[c]
+
+                if fp_map[fingerprint] in profile_config:
+                    # Swap the two entries
+                    profile_config[c] = profile_config[fp_map[fingerprint]]
+                    profile_config[c].output = c
+                else:
+                    # Object is reassigned to another key, drop this one
+                    del profile_config[c]
+
+                profile_config[fp_map[fingerprint]] = tmp_disp
+                profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
+
+
 def find_profiles(current_config, profiles):
-    "Find profiles matching the currently connected outputs"
+    "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
     detected_profiles = []
     for profile_name, profile in profiles.items():
         config = profile["config"]
         matches = True
         for name, output in config.items():
-            if not output.edid:
+            if not output.fingerprint:
                 continue
-            if name not in current_config or not output.edid_equals(current_config[name]):
+            if name not in current_config or not output.fingerprint_equals(current_config[name]):
                 matches = False
                 break
-        if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
+        if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
             continue
         if matches:
-            detected_profiles.append(profile_name)
+            closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
+                current_config[name].edid, output.edid))
+            detected_profiles.append((closeness, profile_name))
+    detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
     return detected_profiles
 
 
@@ -571,6 +782,17 @@ def profile_blocked(profile_path, meta_information=None):
     return not exec_scripts(profile_path, "block", meta_information)
 
 
+def check_configuration_pre_save(configuration):
+    "Check that a configuration is safe for saving."
+    outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
+    for output in outputs:
+        if "off" not in configuration[output].options and not configuration[output].edid:
+            return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
+                    "This typically means that it has been recently unplugged and then not properly disabled\n"
+                    "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
+                    "this command.") % {"o": output}
+
+
 def output_configuration(configuration, config):
     "Write a configuration file"
     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
@@ -586,13 +808,20 @@ def output_setup(configuration, setup):
             print(output, configuration[output].edid, file=setup)
 
 
-def save_configuration(profile_path, configuration):
+def save_configuration(profile_path, profile_name, configuration, forced=False):
     "Save a configuration into a profile"
     if not os.path.isdir(profile_path):
         os.makedirs(profile_path)
-    with open(os.path.join(profile_path, "config"), "w") as config:
+    config_path = os.path.join(profile_path, "config")
+    setup_path = os.path.join(profile_path, "setup")
+    if os.path.isfile(config_path) and not forced:
+        raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
+    if os.path.isfile(setup_path) and not forced:
+        raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
+
+    with open(config_path, "w") as config:
         output_configuration(configuration, config)
-    with open(os.path.join(profile_path, "setup"), "w") as setup:
+    with open(setup_path, "w") as setup:
         output_setup(configuration, setup)
 
 
@@ -612,23 +841,22 @@ def call_and_retry(*args, **kwargs):
     waits a second and then retries once. This mitigates #47,
     a timing issue with some drivers.
     """
-    if "dry_run" in kwargs:
-        dry_run = kwargs["dry_run"]
-        del kwargs["dry_run"]
+    if kwargs.pop("dry_run", False):
+        for arg in args[0]:
+            print(shlex.quote(arg), end=" ")
+        print()
+        return 0
     else:
-        dry_run = False
-    kwargs_redirected = dict(kwargs)
-    if not dry_run:
         if hasattr(subprocess, "DEVNULL"):
-            kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
+            kwargs["stdout"] = getattr(subprocess, "DEVNULL")
         else:
-            kwargs_redirected["stdout"] = open(os.devnull, "w")
-        kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
-    retval = subprocess.call(*args, **kwargs_redirected)
-    if retval != 0:
-        time.sleep(1)
+            kwargs["stdout"] = open(os.devnull, "w")
+        kwargs["stderr"] = kwargs["stdout"]
         retval = subprocess.call(*args, **kwargs)
-    return retval
+        if retval != 0:
+            time.sleep(1)
+            retval = subprocess.call(*args, **kwargs)
+        return retval
 
 
 def get_fb_dimensions(configuration):
@@ -638,7 +866,10 @@ def get_fb_dimensions(configuration):
         if "off" in output.options or not output.edid:
             continue
         # This won't work with all modes -- but it's a best effort.
-        o_mode = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"]).group(0)
+        match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
+        if not match:
+            return None
+        o_mode = match.group(0)
         o_width, o_height = map(int, o_mode.split("x"))
         if "transform" in output.options:
             a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
@@ -661,16 +892,16 @@ def get_fb_dimensions(configuration):
                 o_height = int(detail.get("h")) + int(detail.get("y"))
         width = max(width, o_width)
         height = max(height, o_height)
-    return int(width), int(height)
+    return math.ceil(width), math.ceil(height)
 
 
 def apply_configuration(new_configuration, current_configuration, dry_run=False):
     "Apply a configuration"
+    found_top_left_monitor = False
+    found_left_monitor = False
+    found_top_monitor = False
     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
-    if dry_run:
-        base_argv = ["echo", "xrandr"]
-    else:
-        base_argv = ["xrandr"]
+    base_argv = ["xrandr"]
 
     # There are several xrandr / driver bugs we need to take care of here:
     # - We cannot enable more than two screens at the same time
@@ -694,10 +925,10 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
 
     fb_dimensions = get_fb_dimensions(new_configuration)
     try:
-        base_argv += ["--fb", "%dx%d" % fb_dimensions]
+        fb_args = ["--fb", "%dx%d" % fb_dimensions]
     except:
         # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
-        pass
+        fb_args = []
 
     auxiliary_changes_pre = []
     disable_outputs = []
@@ -707,6 +938,9 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
         if not new_configuration[output].edid or "off" in new_configuration[output].options:
             disable_outputs.append(new_configuration[output].option_vector)
         else:
+            if output not in current_configuration:
+                raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
+                                         "Don't know how to proceed." % output)
             if "off" not in current_configuration[output].options:
                 remain_active_count += 1
 
@@ -722,14 +956,32 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
                                 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
                         except ValueError:
                             pass
-
-            enable_outputs.append(option_vector)
+            if not found_top_left_monitor:
+                position = new_configuration[output].options.get("pos", "0x0")
+                if position == "0x0":
+                    found_top_left_monitor = True
+                    enable_outputs.insert(0, option_vector)
+                elif not found_left_monitor and position.startswith("0x"):
+                    found_left_monitor = True
+                    enable_outputs.insert(0, option_vector)
+                elif not found_top_monitor and position.endswith("x0"):
+                    found_top_monitor = True
+                    enable_outputs.insert(0, option_vector)
+                else:
+                    enable_outputs.append(option_vector)
+            else:
+                enable_outputs.append(option_vector)
 
     # Perform pe-change auxiliary changes
     if auxiliary_changes_pre:
         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
         if call_and_retry(argv, dry_run=dry_run) != 0:
-            raise AutorandrException("Command failed: %s" % " ".join(argv))
+            raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
+    # Starting here, fix the frame buffer size
+    # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
+    # dimensions larger than they will finally be.
+    base_argv += fb_args
 
     # Disable unused outputs, but make sure that there always is at least one active screen
     disable_keep = 0 if remain_active_count else 1
@@ -750,12 +1002,26 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
         # In the context of a xrandr call that changes the display state, `--query' should do nothing
         disable_outputs.insert(0, ['--query'])
 
+    # If we did not find a candidate, we might need to inject a call
+    # If there is no output to disable, we will enable 0x and x0 at the same time
+    if not found_top_left_monitor and len(disable_outputs) > 0:
+        # If the call to 0x and x0 is split, inject one of them
+        if found_top_monitor and found_left_monitor:
+            enable_outputs.insert(0, enable_outputs[0])
+
     # Enable the remaining outputs in pairs of two operations
     operations = disable_outputs + enable_outputs
     for index in range(0, len(operations), 2):
         argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
         if call_and_retry(argv, dry_run=dry_run) != 0:
-            raise AutorandrException("Command failed: %s" % " ".join(argv))
+            raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
+    # Adjust the frame buffer to match (see #319)
+    if fb_args:
+        argv = base_argv
+        if call_and_retry(argv, dry_run=dry_run) != 0:
+            raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
+
 
 
 def is_equal_configuration(source_configuration, target_configuration):
@@ -820,16 +1086,18 @@ def generate_virtual_profile(configuration, modes, profile_name):
                     configuration[output].options["pos"] = "0x0"
                 else:
                     configuration[output].options["off"] = None
-    elif profile_name in ("horizontal", "vertical"):
+    elif profile_name in ("horizontal", "vertical", "horizontal-reverse", "vertical-reverse"):
         shift = 0
-        if profile_name == "horizontal":
+        if profile_name.startswith("horizontal"):
             shift_index = "width"
             pos_specifier = "%sx0"
         else:
             shift_index = "height"
             pos_specifier = "0x%s"
-
-        for output in configuration:
+            
+        config_iter = reversed(configuration) if "reverse" in profile_name else iter(configuration)
+            
+        for output in config_iter:
             configuration[output].options = {}
             if output in modes and configuration[output].edid:
                 def key(a):
@@ -938,11 +1206,12 @@ def exec_scripts(profile_path, script_name, meta_information=None):
     if not os.path.isdir(user_profile_path):
         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
 
-    candidate_directories = [user_profile_path]
-    for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
-        candidate_directories.append(os.path.join(config_dir, "autorandr"))
+    candidate_directories = []
     if profile_path:
         candidate_directories.append(profile_path)
+    candidate_directories.append(user_profile_path)
+    for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
+        candidate_directories.append(os.path.join(config_dir, "autorandr"))
 
     for folder in candidate_directories:
         if script_name not in ran_scripts:
@@ -950,21 +1219,21 @@ def exec_scripts(profile_path, script_name, meta_information=None):
             if os.access(script, os.X_OK | os.F_OK):
                 try:
                     all_ok &= subprocess.call(script, env=env) != 0
-                except:
-                    raise AutorandrException("Failed to execute user command: %s" % (script,))
+                except Exception as e:
+                    raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
                 ran_scripts.add(script_name)
 
         script_folder = os.path.join(folder, "%s.d" % script_name)
         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
-            for file_name in os.listdir(script_folder):
+            for file_name in sorted(os.listdir(script_folder)):
                 check_name = "d/%s" % (file_name,)
                 if check_name not in ran_scripts:
                     script = os.path.join(script_folder, file_name)
                     if os.access(script, os.X_OK | os.F_OK):
                         try:
                             all_ok &= subprocess.call(script, env=env) != 0
-                        except:
-                            raise AutorandrException("Failed to execute user command: %s" % (script,))
+                        except Exception as e:
+                            raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
                         ran_scripts.add(check_name)
 
     return all_ok
@@ -1001,16 +1270,33 @@ def dispatch_call_to_sessions(argv):
             # so it should be safe. Also, note that since the environment
             # is taken from a process owned by the user, reusing it should
             # not leak any information.
-            os.setgroups([])
+            try:
+                os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
+            except AttributeError:
+                # Python 2 doesn't have getgrouplist
+                os.setgroups([])
             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
             os.chdir(pwent.pw_dir)
             os.environ.clear()
             os.environ.update(process_environ)
-            os.execl(autorandr_binary, autorandr_binary, *argv[1:])
-            os.exit(1)
+            if sys.executable != "" and sys.executable != None:
+                os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
+            else:
+                os.execl(autorandr_binary, autorandr_binary, *argv[1:])
+            sys.exit(1)
         os.waitpid(child_pid, 0)
 
+    # The following line assumes that user accounts start at 1000 and that no
+    # one works using the root or another system account. This is rather
+    # restrictive, but de facto default. If this breaks your use case, set the
+    # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
+    # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
+    # effectively, both values aren't binding in any way.)
+    uid_min = 1000
+    if 'AUTORANDR_UID_MIN' in os.environ:
+      uid_min = int(os.environ['AUTORANDR_UID_MIN'])
+
     for directory in os.listdir("/proc"):
         directory = os.path.join("/proc/", directory)
         if not os.path.isdir(directory):
@@ -1020,17 +1306,15 @@ def dispatch_call_to_sessions(argv):
             continue
         uid = os.stat(environ_file).st_uid
 
-        # The following line assumes that user accounts start at 1000 and that
-        # no one works using the root or another system account. This is rather
-        # restrictive, but de facto default. Alternatives would be to use the
-        # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
-        # but effectively, both values aren't binding in any way.
-        # If this breaks your use case, please file a bug on Github.
-        if uid < 1000:
+        if uid < uid_min:
             continue
 
         process_environ = {}
-        for environ_entry in open(environ_file).read().split("\0"):
+        for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
+            try:
+                environ_entry = environ_entry.decode("ascii")
+            except UnicodeDecodeError:
+                continue
             name, sep, value = environ_entry.partition("=")
             if name and sep:
                 if name == "DISPLAY" and "." in value:
@@ -1041,6 +1325,11 @@ def dispatch_call_to_sessions(argv):
             # Cannot work with this environment, skip.
             continue
 
+        if "WAYLAND_DISPLAY" in process_environ and process_environ["WAYLAND_DISPLAY"]:
+            if "--debug" in argv:
+                print("Detected Wayland session '{0}'. Skipping.".format(process_environ["WAYLAND_DISPLAY"]))
+            continue
+
         # To allow scripts to detect batch invocation (especially useful for predetect)
         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
         process_environ["UID"] = str(uid)
@@ -1097,10 +1386,32 @@ def read_config(options, directory):
 
 def main(argv):
     try:
-        opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
-                                   ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
-                                    "force", "fingerprint", "config", "debug", "skip-options=", "help",
-                                    "current", "detected", "version"])
+        opts, args = getopt.getopt(
+            argv[1:],
+            "s:r:l:d:cfh",
+            [
+                "batch",
+                "dry-run",
+                "change",
+                "cycle",
+                "default=",
+                "save=",
+                "remove=",
+                "load=",
+                "force",
+                "fingerprint",
+                "config",
+                "debug",
+                "skip-options=",
+                "help",
+                "list",
+                "current",
+                "detected",
+                "version",
+                "match-edid",
+                "ignore-lid"
+            ]
+        )
     except getopt.GetoptError as e:
         print("Failed to parse options: {0}.\n"
               "Use --help to get usage information.".format(str(e)),
@@ -1131,6 +1442,9 @@ def main(argv):
         user = pwd.getpwuid(os.getuid())
         user = user.pw_name if user else "#%d" % os.getuid()
         print("autorandr running as user %s (started from batch instance)" % user)
+    if ("WAYLAND_DISPLAY" in os.environ and os.environ["WAYLAND_DISPLAY"]):
+        print("Detected Wayland session '{0}'. Exiting.".format(os.environ["WAYLAND_DISPLAY"]), file=sys.stderr)
+        sys.exit(1)
 
     profiles = {}
     profile_symlinks = {}
@@ -1153,15 +1467,27 @@ def main(argv):
             profiles.update(load_profiles(profile_path))
             profile_symlinks.update(get_symlinks(profile_path))
             read_config(options, profile_path)
-        # Sort by descending mtime
-        profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
     except Exception as e:
         raise AutorandrException("Failed to load profiles", e)
 
-    profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
-
     exec_scripts(None, "predetect")
-    config, modes = parse_xrandr_output()
+
+    ignore_lid = "--ignore-lid" in options
+
+    config, modes = parse_xrandr_output(
+        ignore_lid=ignore_lid,
+    )
+
+    if "--match-edid" in options:
+        update_profiles_edid(profiles, config)
+
+    # Sort by mtime
+    sort_direction = -1
+    if "--cycle" in options:
+        # When cycling through profiles, put the profile least recently used to the top of the list
+        sort_direction = 1
+    profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
+    profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
 
     if "--fingerprint" in options:
         output_setup(config, sys.stdout)
@@ -1185,14 +1511,21 @@ def main(argv):
         if options["--save"] in (x[0] for x in virtual_profiles):
             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
                                      "This configuration name is a reserved virtual configuration." % options["--save"])
+        error = check_configuration_pre_save(config)
+        if error:
+            print("Cannot save current configuration as profile '%s':" % options["--save"])
+            print(error)
+            sys.exit(1)
         try:
             profile_folder = os.path.join(profile_path, options["--save"])
-            save_configuration(profile_folder, config)
+            save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
             exec_scripts(profile_folder, "postsave", {
                 "CURRENT_PROFILE": options["--save"],
                 "PROFILE_FOLDER": profile_folder,
                 "MONITORS": ":".join(enabled_monitors(config)),
             })
+        except AutorandrException as e:
+            raise e
         except Exception as e:
             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
         print("Saved current configuration as profile '%s'" % options["--save"])
@@ -1249,23 +1582,32 @@ def main(argv):
             "CURRENT_PROFILES": ":".join(current_profiles)
         }
 
+        best_index = 9999
         for profile_name in profiles.keys():
             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
-                if "--current" not in options and "--detected" not in options:
+                if not any(opt in options for opt in ("--current", "--detected", "--list")):
                     print("%s (blocked)" % profile_name)
                 continue
             props = []
+            is_current_profile = profile_name in current_profiles
             if profile_name in detected_profiles:
-                props.append("(detected)")
-                if ("-c" in options or "--change" in options) and not load_profile:
-                    load_profile = profile_name
+                if len(detected_profiles) == 1:
+                    index = 1
+                    props.append("(detected)")
+                else:
+                    index = detected_profiles.index(profile_name) + 1
+                    props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
+                if index < best_index:
+                    if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
+                        load_profile = profile_name
+                        best_index = index
             elif "--detected" in options:
                 continue
-            if profile_name in current_profiles:
+            if is_current_profile:
                 props.append("(current)")
             elif "--current" in options:
                 continue
-            if "--current" in options or "--detected" in options:
+            if any(opt in options for opt in ("--current", "--detected", "--list")):
                 print("%s" % (profile_name, ))
             else:
                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
@@ -1274,7 +1616,7 @@ def main(argv):
 
     if "-d" in options:
         options["--default"] = options["-d"]
-    if not load_profile and "--default" in options and ("-c" in options or "--change" in options):
+    if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
         load_profile = options["--default"]
 
     if load_profile:
@@ -1293,7 +1635,7 @@ def main(argv):
                 scripts_path = profile["path"]
             except KeyError:
                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
-            if load_profile in detected_profiles and detected_profiles[0] != load_profile:
+            if "--dry-run" not in options:
                 update_mtime(os.path.join(scripts_path, "config"))
         add_unused_outputs(config, load_config)
         if load_config == dict(config) and "-f" not in options and "--force" not in options:
@@ -1326,7 +1668,12 @@ def main(argv):
             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
 
         if "--dry-run" not in options and "--debug" in options:
-            new_config, _ = parse_xrandr_output()
+            new_config, _ = parse_xrandr_output(
+                ignore_lid=ignore_lid,
+            )
+            if "--skip-options" in options:
+                for output in new_config.values():
+                    output.set_ignored_options(skip_options)
             if not is_equal_configuration(new_config, load_config):
                 print("The configuration change did not go as expected:")
                 print_profile_differences(new_config, load_config)