]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blobdiff - autorandr.py
Quote arguments in failed command output
[deb_pkgs/autorandr.git] / autorandr.py
index b7940bc23c4eeaa5d6926118678298d82fcde914..d9b853b6fb97ef2ba9f5aa9df462571b5159cb04 100755 (executable)
@@ -28,6 +28,7 @@ import binascii
 import copy
 import getopt
 import hashlib
+import math
 import os
 import posix
 import pwd
@@ -100,6 +101,7 @@ Usage: autorandr [options]
 --detected              only list detected (available) configuration(s)
 --dry-run               don't change anything, only print the xrandr commands
 --fingerprint           fingerprint your current hardware setup
+--ignore-lid            treat outputs as connected even if their lids are closed
 --match-edid            match diplays based on edid instead of name
 --force                 force (re)loading of a profile / overwrite exiting files
 --list                  list configurations
@@ -202,6 +204,7 @@ class XrandrOutput(object):
             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
@@ -237,7 +240,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):
@@ -254,12 +257,17 @@ 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):
@@ -316,8 +324,34 @@ class XrandrOutput(object):
         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
+            # 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)
@@ -419,6 +453,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
@@ -437,7 +473,7 @@ class XrandrOutput(object):
         return XrandrOutput(match["output"], edid, options), modes
 
     @classmethod
-    def from_config_file(cls, edid_map, configuration):
+    def from_config_file(cls, profile, edid_map, configuration):
         "Instanciate an XrandrOutput from the contents of a configuration file"
         options = {}
         for line in configuration.split("\n"):
@@ -458,13 +494,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:
@@ -482,13 +528,13 @@ class XrandrOutput(object):
         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:
@@ -595,7 +641,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)
@@ -647,33 +693,33 @@ def match_asterisk(pattern, data):
 
 
 def update_profiles_edid(profiles, config):
-    edid_map = {}
+    fp_map = {}
     for c in config:
-        if config[c].edid is not None:
-            edid_map[config[c].edid] = c
+        if config[c].fingerprint is not None:
+            fp_map[config[c].fingerprint] = c
 
     for p in profiles:
         profile_config = profiles[p]["config"]
 
-        for edid in edid_map:
+        for fingerprint in fp_map:
             for c in list(profile_config.keys()):
-                if profile_config[c].edid != edid or c == edid_map[edid]:
+                if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
                     continue
 
-                print("%s: renaming display %s to %s" % (p, c, edid_map[edid]))
+                print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]))
 
                 tmp_disp = profile_config[c]
 
-                if edid_map[edid] in profile_config:
+                if fp_map[fingerprint] in profile_config:
                     # Swap the two entries
-                    profile_config[c] = profile_config[edid_map[edid]]
+                    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[edid_map[edid]] = tmp_disp
-                profile_config[edid_map[edid]].output = edid_map[edid]
+                profile_config[fp_map[fingerprint]] = tmp_disp
+                profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
 
 
 def find_profiles(current_config, profiles):
@@ -683,12 +729,12 @@ def find_profiles(current_config, profiles):
         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:
             closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
@@ -817,7 +863,7 @@ 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):
@@ -850,10 +896,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 = []
@@ -901,7 +947,12 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
     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
@@ -934,7 +985,7 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
     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)))
 
 
 def is_equal_configuration(source_configuration, target_configuration):
@@ -1198,6 +1249,16 @@ def dispatch_call_to_sessions(argv):
             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):
@@ -1207,13 +1268,7 @@ 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 = {}
@@ -1570,6 +1625,9 @@ def main(argv):
             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)