]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Retry failed xrandr calls after waiting for one second to mitigate #47
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
15 #
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
20 #
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 #
24
25 from __future__ import print_function
26 import time
27
28 import binascii
29 import copy
30 import getopt
31 import hashlib
32 import os
33 import posix
34 import re
35 import subprocess
36 import sys
37 import shutil
38
39 from collections import OrderedDict
40 from distutils.version import LooseVersion as Version
41 from functools import reduce
42 from itertools import chain
43
44 try:
45     input = raw_input
46 except NameError:
47     pass
48
49 virtual_profiles = [
50     # (name, description, callback)
51     ("common", "Clone all connected outputs at the largest common resolution", None),
52     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
53     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
54 ]
55
56 help_text = """
57 Usage: autorandr [options]
58
59 -h, --help              get this small help
60 -c, --change            reload current setup
61 -s, --save <profile>    save your current setup to profile <profile>
62 -r, --remove <profile>  remove profile <profile>
63 -l, --load <profile>    load profile <profile>
64 -d, --default <profile> make profile <profile> the default profile
65 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
66                         to skip both in detecting changes and applying a profile
67 --force                 force (re)loading of a profile
68 --fingerprint           fingerprint your current hardware setup
69 --config                dump your current xrandr setup
70 --dry-run               don't change anything, only print the xrandr commands
71 --debug                 enable verbose output
72
73  To prevent a profile from being loaded, place a script call "block" in its
74  directory. The script is evaluated before the screen setup is inspected, and
75  in case of it returning a value of 0 the profile is skipped. This can be used
76  to query the status of a docking station you are about to leave.
77
78  If no suitable profile can be identified, the current configuration is kept.
79  To change this behaviour and switch to a fallback configuration, specify
80  --default <profile>.
81
82  Another script called "postswitch" can be placed in the directory
83  ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
84  as in any profile directories: The scripts are executed after a mode switch
85  has taken place and can notify window managers.
86
87  The following virtual configurations are available:
88 """.strip()
89
90 class AutorandrException(Exception):
91     def __init__(self, message, original_exception=None, report_bug=False):
92         self.message = message
93         self.report_bug = report_bug
94         if original_exception:
95             self.original_exception = original_exception
96             trace = sys.exc_info()[2]
97             while trace.tb_next:
98                 trace = trace.tb_next
99             self.line = trace.tb_lineno
100         else:
101             try:
102                 import inspect
103                 self.line = inspect.currentframe().f_back.f_lineno
104             except:
105                 self.line = None
106             self.original_exception = None
107
108     def __str__(self):
109         retval = [ self.message ]
110         if self.line:
111             retval.append(" (line %d)" % self.line)
112         if self.original_exception:
113             retval.append(":\n  ")
114             retval.append(str(self.original_exception).replace("\n", "\n  "))
115         if self.report_bug:
116             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream."
117                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
118         return "".join(retval)
119
120 class XrandrOutput(object):
121     "Represents an XRandR output"
122
123     # This regular expression is used to parse an output in `xrandr --verbose'
124     XRANDR_OUTPUT_REGEXP = """(?x)
125         ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
126         (?:                                                                             # Differentiate disconnected and connected in first line
127             disconnected |
128             unknown\ connection |
129             (?P<connected>connected)
130         )
131         \s*
132         (?P<primary>primary\ )?                                                         # Might be primary screen
133         (?:\s*
134             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
135             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
136             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
137             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
138             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
139         )?                                                                              # .. but everything of the above only if the screen is in use.
140         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
141         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
142         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
143         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
144         (?:\s*(?:                                                                       # Properties of the output
145             Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
146             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
147             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
148             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
149         ))+
150         \s*
151         (?P<modes>(?:
152             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution: Extract rate
153              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
154              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
155             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
156         )*)
157     """
158
159     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
160         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
161          h:\s+width\s+(?P<width>[0-9]+).+\s+
162          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
163     """
164
165     XRANDR_13_DEFAULTS = {
166         "transform": "1,0,0,0,1,0,0,0,1",
167         "panning": "0x0",
168     }
169
170     XRANDR_12_DEFAULTS = {
171         "reflect": "normal",
172         "rotate": "normal",
173         "gamma": "1.0:1.0:1.0",
174     }
175
176     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
177
178     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
179
180     def __repr__(self):
181         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
182
183     @property
184     def short_edid(self):
185         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
186
187     @property
188     def options_with_defaults(self):
189         "Return the options dictionary, augmented with the default values that weren't set"
190         if "off" in self.options:
191             return self.options
192         options = {}
193         if xrandr_version() >= Version("1.3"):
194             options.update(self.XRANDR_13_DEFAULTS)
195         if xrandr_version() >= Version("1.2"):
196             options.update(self.XRANDR_12_DEFAULTS)
197         options.update(self.options)
198         return { a: b for a, b in options.items() if a not in self.ignored_options }
199
200     @property
201     def filtered_options(self):
202         "Return a dictionary of options without ignored options"
203         return { a: b for a, b in self.options.items() if a not in self.ignored_options }
204
205     @property
206     def option_vector(self):
207         "Return the command line parameters for XRandR for this instance"
208         return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), sorted(self.options_with_defaults.items()))], [])
209
210     @property
211     def option_string(self):
212         "Return the command line parameters in the configuration file format"
213         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
214
215     @property
216     def sort_key(self):
217         "Return a key to sort the outputs for xrandr invocation"
218         if not self.edid:
219             return -2
220         if "off" in self.options:
221             return -1
222         if "pos" in self.options:
223             x, y = map(float, self.options["pos"].split("x"))
224         else:
225             x, y = 0, 0
226         return x + 10000 * y
227
228     def __init__(self, output, edid, options):
229         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
230         self.output = output
231         self.edid = edid
232         self.options = options
233         self.ignored_options = []
234         self.remove_default_option_values()
235
236     def set_ignored_options(self, options):
237         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
238         self.ignored_options = list(options)
239
240     def remove_default_option_values(self):
241         "Remove values from the options dictionary that are superflous"
242         if "off" in self.options and len(self.options.keys()) > 1:
243             self.options = { "off": None }
244             return
245         for option, default_value in self.XRANDR_DEFAULTS.items():
246             if option in self.options and self.options[option] == default_value:
247                 del self.options[option]
248
249     @classmethod
250     def from_xrandr_output(cls, xrandr_output):
251         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
252
253         This method also returns a list of modes supported by the output.
254         """
255         try:
256             xrandr_output = xrandr_output.replace("\r\n", "\n")
257             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
258         except:
259             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
260         if not match_object:
261             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
262             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
263         remainder = xrandr_output[len(match_object.group(0)):]
264         if remainder:
265             raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
266                                 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
267
268         match = match_object.groupdict()
269
270         modes = []
271         if match["modes"]:
272             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
273             if not modes:
274                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
275
276         options = {}
277         if not match["connected"]:
278             edid = None
279         else:
280             edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
281
282         if not match["width"]:
283             options["off"] = None
284         else:
285             if match["mode_name"]:
286                 options["mode"] = match["mode_name"]
287             elif match["mode_width"]:
288                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
289             else:
290                 if match["rotate"] not in ("left", "right"):
291                     options["mode"] = "%sx%s" % (match["width"], match["height"])
292                 else:
293                     options["mode"] = "%sx%s" % (match["height"], match["width"])
294             options["rotate"] = match["rotate"]
295             if match["primary"]:
296                 options["primary"] = None
297             if match["reflect"] == "X":
298                 options["reflect"] = "x"
299             elif match["reflect"] == "Y":
300                 options["reflect"] = "y"
301             elif match["reflect"] == "X and Y":
302                 options["reflect"] = "xy"
303             options["pos"] = "%sx%s" % (match["x"], match["y"])
304             if match["panning"]:
305                 panning = [ match["panning"] ]
306                 if match["tracking"]:
307                     panning += [ "/", match["tracking"] ]
308                     if match["border"]:
309                         panning += [ "/", match["border"] ]
310                 options["panning"] = "".join(panning)
311             if match["transform"]:
312                 transformation = ",".join(match["transform"].strip().split())
313                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
314                     options["transform"] = transformation
315                     if not match["mode_name"]:
316                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
317                         # special case is actually required.
318                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
319             if match["gamma"]:
320                 gamma = match["gamma"].strip()
321                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
322                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
323                 # so we approximate by 1e-10.
324                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
325                 options["gamma"] = gamma
326             if match["rate"]:
327                 options["rate"] = match["rate"]
328
329         return XrandrOutput(match["output"], edid, options), modes
330
331     @classmethod
332     def from_config_file(cls, edid_map, configuration):
333         "Instanciate an XrandrOutput from the contents of a configuration file"
334         options = {}
335         for line in configuration.split("\n"):
336             if line:
337                 line = line.split(None, 1)
338                 options[line[0]] = line[1] if len(line) > 1 else None
339
340         edid = None
341
342         if options["output"] in edid_map:
343             edid = edid_map[options["output"]]
344         else:
345             # This fuzzy matching is for legacy autorandr that used sysfs output names
346             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
347             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
348             if fuzzy_output in fuzzy_edid_map:
349                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
350             elif "off" not in options:
351                 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"]))
352         output = options["output"]
353         del options["output"]
354
355         return XrandrOutput(output, edid, options)
356
357     def edid_equals(self, other):
358         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
359         if self.edid and other.edid:
360             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
361                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
362             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
363                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
364         return self.edid == other.edid
365
366     def __ne__(self, other):
367         return not (self == other)
368
369     def __eq__(self, other):
370         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
371
372     def verbose_diff(self, other):
373         "Compare to another XrandrOutput and return a list of human readable differences"
374         diffs = []
375         if not self.edid_equals(other):
376             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
377         if self.output != other.output:
378             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
379         if "off" in self.options and "off" not in other.options:
380             diffs.append("The output is disabled currently, but active in the new configuration")
381         elif "off" in other.options and "off" not in self.options:
382             diffs.append("The output is currently enabled, but inactive in the new configuration")
383         else:
384             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
385                 if name not in other.options:
386                     diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
387                 elif name not in self.options:
388                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
389                 elif self.options[name] != other.options[name]:
390                     diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
391         return diffs
392
393 def xrandr_version():
394     "Return the version of XRandR that this system uses"
395     if getattr(xrandr_version, "version", False) is False:
396         version_string = os.popen("xrandr -v").read()
397         try:
398             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
399             xrandr_version.version = Version(version)
400         except AttributeError:
401             xrandr_version.version = Version("1.3.0")
402
403     return xrandr_version.version
404
405 def debug_regexp(pattern, string):
406     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
407     try:
408         import regex
409         bounds = ( 0, len(string) )
410         while bounds[0] != bounds[1]:
411             half = int((bounds[0] + bounds[1]) / 2)
412             if half == bounds[0]:
413                 break
414             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
415         partial_length = bounds[0]
416         return ("Regular expression matched until position "
417               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
418                                                              string[partial_length:partial_length+10]))
419     except ImportError:
420         pass
421     return "Debug information would be available if the `regex' module was installed."
422
423 def parse_xrandr_output():
424     "Parse the output of `xrandr --verbose' into a list of outputs"
425     xrandr_output = os.popen("xrandr -q --verbose").read()
426     if not xrandr_output:
427         raise AutorandrException("Failed to run xrandr")
428
429     # We are not interested in screens
430     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
431
432     # Split at output boundaries and instanciate an XrandrOutput per output
433     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
434     if len(split_xrandr_output) < 2:
435         raise AutorandrException("No output boundaries found", report_bug=True)
436     outputs = OrderedDict()
437     modes = OrderedDict()
438     for i in range(1, len(split_xrandr_output), 2):
439         output_name = split_xrandr_output[i].split()[0]
440         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
441         outputs[output_name] = output
442         if output_modes:
443             modes[output_name] = output_modes
444
445     return outputs, modes
446
447 def load_profiles(profile_path):
448     "Load the stored profiles"
449
450     profiles = {}
451     for profile in os.listdir(profile_path):
452         config_name = os.path.join(profile_path, profile, "config")
453         setup_name  = os.path.join(profile_path, profile, "setup")
454         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
455             continue
456
457         edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
458
459         config = {}
460         buffer = []
461         for line in chain(open(config_name).readlines(), ["output"]):
462             if line[:6] == "output" and buffer:
463                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
464                 buffer = [ line ]
465             else:
466                 buffer.append(line)
467
468         for output_name in list(config.keys()):
469             if config[output_name].edid is None:
470                 del config[output_name]
471
472         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
473
474     return profiles
475
476 def find_profiles(current_config, profiles):
477     "Find profiles matching the currently connected outputs"
478     detected_profiles = []
479     for profile_name, profile in profiles.items():
480         config = profile["config"]
481         matches = True
482         for name, output in config.items():
483             if not output.edid:
484                 continue
485             if name not in current_config or not output.edid_equals(current_config[name]):
486                 matches = False
487                 break
488         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
489             continue
490         if matches:
491             detected_profiles.append(profile_name)
492     return detected_profiles
493
494 def profile_blocked(profile_path, meta_information=None):
495     """Check if a profile is blocked.
496
497     meta_information is expected to be an dictionary. It will be passed to the block scripts
498     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
499     """
500     return not exec_scripts(profile_path, "block", meta_information)
501
502 def output_configuration(configuration, config):
503     "Write a configuration file"
504     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
505     for output in outputs:
506         print(configuration[output].option_string, file=config)
507
508 def output_setup(configuration, setup):
509     "Write a setup (fingerprint) file"
510     outputs = sorted(configuration.keys())
511     for output in outputs:
512         if configuration[output].edid:
513             print(output, configuration[output].edid, file=setup)
514
515 def save_configuration(profile_path, configuration):
516     "Save a configuration into a profile"
517     if not os.path.isdir(profile_path):
518         os.makedirs(profile_path)
519     with open(os.path.join(profile_path, "config"), "w") as config:
520         output_configuration(configuration, config)
521     with open(os.path.join(profile_path, "setup"), "w") as setup:
522         output_setup(configuration, setup)
523
524 def update_mtime(filename):
525     "Update a file's mtime"
526     try:
527         os.utime(filename, None)
528         return True
529     except:
530         return False
531
532 def call_and_retry(*args, **kwargs):
533     """Wrapper around subprocess.call that retries failed calls.
534
535     This function calls subprocess.call and on non-zero exit states,
536     waits a second and then retries once. This mitigates #47,
537     a timing issue with some drivers.
538     """
539     kwargs_redirected = dict(kwargs)
540     if hasattr(subprocess, "DEVNULL"):
541         kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
542     else:
543         kwargs_redirected["stdout"] = open(os.devnull, "w")
544     kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
545     retval = subprocess.call(*args, **kwargs_redirected)
546     if retval != 0:
547         time.sleep(1)
548         retval = subprocess.call(*args, **kwargs)
549     return retval
550
551 def apply_configuration(new_configuration, current_configuration, dry_run=False):
552     "Apply a configuration"
553     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
554     if dry_run:
555         base_argv = [ "echo", "xrandr" ]
556     else:
557         base_argv = [ "xrandr" ]
558
559     # There are several xrandr / driver bugs we need to take care of here:
560     # - We cannot enable more than two screens at the same time
561     #   See https://github.com/phillipberndt/autorandr/pull/6
562     #   and commits f4cce4d and 8429886.
563     # - We cannot disable all screens
564     #   See https://github.com/phillipberndt/autorandr/pull/20
565     # - We should disable screens before enabling others, because there's
566     #   a limit on the number of enabled screens
567     # - We must make sure that the screen at 0x0 is activated first,
568     #   or the other (first) screen to be activated would be moved there.
569     # - If an active screen already has a transformation and remains active,
570     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
571     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
572     #   at least.)
573     # - Some implementations can not handle --transform at all, so avoid it unless
574     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
575
576     auxiliary_changes_pre = []
577     disable_outputs = []
578     enable_outputs = []
579     remain_active_count = 0
580     for output in outputs:
581         if not new_configuration[output].edid or "off" in new_configuration[output].options:
582             disable_outputs.append(new_configuration[output].option_vector)
583         else:
584             if "off" not in current_configuration[output].options:
585                 remain_active_count += 1
586
587             option_vector = new_configuration[output].option_vector
588             if xrandr_version() >= Version("1.3.0"):
589                 if "transform" in current_configuration[output].options:
590                     auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
591                 else:
592                     try:
593                         transform_index = option_vector.index("--transform")
594                         if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
595                             option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
596                     except ValueError:
597                         pass
598
599             enable_outputs.append(option_vector)
600
601     # Perform pe-change auxiliary changes
602     if auxiliary_changes_pre:
603         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
604         if call_and_retry(argv) != 0:
605             raise AutorandrException("Command failed: %s" % " ".join(argv))
606
607     # Disable unused outputs, but make sure that there always is at least one active screen
608     disable_keep = 0 if remain_active_count else 1
609     if len(disable_outputs) > disable_keep:
610         if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
611             # Disabling the outputs failed. Retry with the next command:
612             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
613             # This does not occur if simultaneously the primary screen is reset.
614             pass
615         else:
616             disable_outputs = disable_outputs[-1:] if disable_keep else []
617
618     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
619     # disable the last two screens. This is a problem, so if this would happen, instead disable only
620     # one screen in the first call below.
621     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
622         # In the context of a xrandr call that changes the display state, `--query' should do nothing
623         disable_outputs.insert(0, ['--query'])
624
625     # Enable the remaining outputs in pairs of two operations
626     operations = disable_outputs + enable_outputs
627     for index in range(0, len(operations), 2):
628         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
629         if call_and_retry(argv) != 0:
630             raise AutorandrException("Command failed: %s" % " ".join(argv))
631
632 def is_equal_configuration(source_configuration, target_configuration):
633     "Check if all outputs from target are already configured correctly in source"
634     for output in target_configuration.keys():
635         if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
636             return False
637     return True
638
639 def add_unused_outputs(source_configuration, target_configuration):
640     "Add outputs that are missing in target to target, in 'off' state"
641     for output_name, output in source_configuration.items():
642         if output_name not in target_configuration:
643             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
644
645 def remove_irrelevant_outputs(source_configuration, target_configuration):
646     "Remove outputs from target that ought to be 'off' and already are"
647     for output_name, output in source_configuration.items():
648         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
649             del target_configuration[output_name]
650
651 def generate_virtual_profile(configuration, modes, profile_name):
652     "Generate one of the virtual profiles"
653     configuration = copy.deepcopy(configuration)
654     if profile_name == "common":
655         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
656         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
657         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
658         if common_resolution:
659             for output in configuration:
660                 configuration[output].options = {}
661                 if output in modes and configuration[output].edid:
662                     configuration[output].options["mode"] = [ x["name"] for x in sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1) if x["width"] == common_resolution[-1][0] and x["height"] == common_resolution[-1][1] ][0]
663                     configuration[output].options["pos"] = "0x0"
664                 else:
665                     configuration[output].options["off"] = None
666     elif profile_name in ("horizontal", "vertical"):
667         shift = 0
668         if profile_name == "horizontal":
669             shift_index = "width"
670             pos_specifier = "%sx0"
671         else:
672             shift_index = "height"
673             pos_specifier = "0x%s"
674
675         for output in configuration:
676             configuration[output].options = {}
677             if output in modes and configuration[output].edid:
678                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
679                 configuration[output].options["mode"] = mode["name"]
680                 configuration[output].options["rate"] = mode["rate"]
681                 configuration[output].options["pos"] = pos_specifier % shift
682                 shift += int(mode[shift_index])
683             else:
684                 configuration[output].options["off"] = None
685     return configuration
686
687 def print_profile_differences(one, another):
688     "Print the differences between two profiles for debugging"
689     if one == another:
690         return
691     print("| Differences between the two profiles:", file=sys.stderr)
692     for output in set(chain.from_iterable((one.keys(), another.keys()))):
693         if output not in one:
694             if "off" not in another[output].options:
695                 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
696         elif output not in another:
697             if "off" not in one[output].options:
698                 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
699         else:
700             for line in one[output].verbose_diff(another[output]):
701                 print("| [Output %s] %s" % (output, line), file=sys.stderr)
702     print ("\\-", file=sys.stderr)
703
704 def exit_help():
705     "Print help and exit"
706     print(help_text)
707     for profile in virtual_profiles:
708         print("  %-10s %s" % profile[:2])
709     sys.exit(0)
710
711 def exec_scripts(profile_path, script_name, meta_information=None):
712     """"Run userscripts
713
714     This will run all executables from the profile folder, and global per-user
715     and system-wide configuration folders, named script_name or residing in
716     subdirectories named script_name.d.
717
718     meta_information is expected to be an dictionary. It will be passed to the block scripts
719     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
720
721     Returns True unless any of the scripts exited with non-zero exit status.
722     """
723     all_ok = True
724     if meta_information:
725         env = os.environ.copy()
726         env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
727     else:
728         env = os.environ.copy()
729
730     # If there are multiple candidates, the XDG spec tells to only use the first one.
731     ran_scripts = set()
732
733     user_profile_path = os.path.expanduser("~/.autorandr")
734     if not os.path.isdir(user_profile_path):
735         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
736
737     for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
738                         (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "").split(":"))):
739
740         if script_name not in ran_scripts:
741             script = os.path.join(folder, script_name)
742             if os.access(script, os.X_OK | os.F_OK):
743                 all_ok &= subprocess.call(script, env=env) != 0
744                 ran_scripts.add(script_name)
745
746         script_folder = os.path.join(folder, "%s.d" % script_name)
747         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
748             for file_name in os.listdir(script_folder):
749                 check_name = "d/%s" % (file_name,)
750                 if check_name not in ran_scripts:
751                     script = os.path.join(script_folder, file_name)
752                     if os.access(script, os.X_OK | os.F_OK):
753                         all_ok &= subprocess.call(script, env=env) != 0
754                         ran_scripts.add(check_name)
755
756     return all_ok
757
758 def main(argv):
759     try:
760         options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
761     except getopt.GetoptError as e:
762         print("Failed to parse options: {0}.\n"
763               "Use --help to get usage information.".format(str(e)),
764               file=sys.stderr)
765         sys.exit(posix.EX_USAGE)
766
767     profiles = {}
768     try:
769         # Load profiles from each XDG config directory
770         # The XDG spec says that earlier entries should take precedence, so reverse the order
771         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "").split(":")):
772             system_profile_path = os.path.join(directory, "autorandr")
773             if os.path.isdir(system_profile_path):
774                 profiles.update(load_profiles(system_profile_path))
775         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
776         # profile_path is also used later on to store configurations
777         profile_path = os.path.expanduser("~/.autorandr")
778         if not os.path.isdir(profile_path):
779             # Elsewise, follow the XDG specification
780             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
781         if os.path.isdir(profile_path):
782             profiles.update(load_profiles(profile_path))
783         # Sort by descending mtime
784         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
785     except Exception as e:
786         raise AutorandrException("Failed to load profiles", e)
787
788     config, modes = parse_xrandr_output()
789
790     if "--fingerprint" in options:
791         output_setup(config, sys.stdout)
792         sys.exit(0)
793
794     if "--config" in options:
795         output_configuration(config, sys.stdout)
796         sys.exit(0)
797
798     if "--skip-options" in options:
799         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
800         for profile in profiles.values():
801             for output in profile["config"].values():
802                 output.set_ignored_options(skip_options)
803         for output in config.values():
804             output.set_ignored_options(skip_options)
805
806     if "-s" in options:
807         options["--save"] = options["-s"]
808     if "--save" in options:
809         if options["--save"] in ( x[0] for x in virtual_profiles ):
810             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
811         try:
812             profile_folder = os.path.join(profile_path, options["--save"])
813             save_configuration(profile_folder, config)
814             exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
815         except Exception as e:
816             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
817         print("Saved current configuration as profile '%s'" % options["--save"])
818         sys.exit(0)
819
820     if "-r" in options:
821         options["--remove"] = options["-r"]
822     if "--remove" in options:
823         if options["--remove"] in ( x[0] for x in virtual_profiles ):
824             raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
825         if options["--remove"] not in profiles.keys():
826             raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
827         try:
828             remove = True
829             profile_folder = os.path.join(profile_path, options["--remove"])
830             profile_dirlist = os.listdir(profile_folder)
831             profile_dirlist.remove("config")
832             profile_dirlist.remove("setup")
833             if profile_dirlist:
834                 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
835                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
836                 if response != "yes":
837                     remove = False
838             if remove is True:
839                 shutil.rmtree(profile_folder)
840                 print("Removed profile '%s'" % options["--remove"])
841             else:
842                 print("Profile '%s' was not removed" % options["--remove"])
843         except Exception as e:
844             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
845         sys.exit(0)
846
847     if "-h" in options or "--help" in options:
848         exit_help()
849
850     detected_profiles = find_profiles(config, profiles)
851     load_profile = False
852
853     if "-l" in options:
854         options["--load"] = options["-l"]
855     if "--load" in options:
856         load_profile = options["--load"]
857     else:
858         # Find the active profile(s) first, for the block script (See #42)
859         current_profiles = []
860         for profile_name in profiles.keys():
861             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
862             if configs_are_equal:
863                 current_profiles.append(profile_name)
864         block_script_metadata = {
865             "CURRENT_PROFILE":  "".join(current_profiles[:1]),
866             "CURRENT_PROFILES": ":".join(current_profiles)
867         }
868
869         for profile_name in profiles.keys():
870             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
871                 print("%s (blocked)" % profile_name, file=sys.stderr)
872                 continue
873             props = []
874             if profile_name in detected_profiles:
875                 props.append("(detected)")
876                 if ("-c" in options or "--change" in options) and not load_profile:
877                     load_profile = profile_name
878             if profile_name in current_profiles:
879                 props.append("(current)")
880             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
881             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
882                 print_profile_differences(config, profiles[profile_name]["config"])
883
884     if "-d" in options:
885         options["--default"] = options["-d"]
886     if not load_profile and "--default" in options:
887         load_profile = options["--default"]
888
889     if load_profile:
890         if load_profile in ( x[0] for x in virtual_profiles ):
891             load_config = generate_virtual_profile(config, modes, load_profile)
892             scripts_path = os.path.join(profile_path, load_profile)
893         else:
894             try:
895                 profile = profiles[load_profile]
896                 load_config = profile["config"]
897                 scripts_path = profile["path"]
898             except KeyError:
899                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
900             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
901                 update_mtime(os.path.join(scripts_path, "config"))
902         add_unused_outputs(config, load_config)
903         if load_config == dict(config) and not "-f" in options and not "--force" in options:
904             print("Config already loaded", file=sys.stderr)
905             sys.exit(0)
906         if "--debug" in options and load_config != dict(config):
907             print("Loading profile '%s'" % load_profile)
908             print_profile_differences(config, load_config)
909
910         remove_irrelevant_outputs(config, load_config)
911
912         try:
913             if "--dry-run" in options:
914                 apply_configuration(load_config, config, True)
915             else:
916                 script_metadata = {
917                     "CURRENT_PROFILE": load_profile,
918                     "PROFILE_FOLDER": scripts_path,
919                 }
920                 exec_scripts(scripts_path, "preswitch", script_metadata)
921                 if "--debug" in options:
922                     print("Going to run:")
923                     apply_configuration(load_config, config, True)
924                 apply_configuration(load_config, config, False)
925                 exec_scripts(scripts_path, "postswitch", script_metadata)
926         except Exception as e:
927             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
928
929         if "--dry-run" not in options and "--debug" in options:
930             new_config, _ = parse_xrandr_output()
931             if not is_equal_configuration(new_config, load_config):
932                 print("The configuration change did not go as expected:")
933                 print_profile_differences(new_config, load_config)
934
935     sys.exit(0)
936
937 if __name__ == '__main__':
938     try:
939         main(sys.argv)
940     except AutorandrException as e:
941         print(e, file=sys.stderr)
942         sys.exit(1)
943     except Exception as e:
944         if not len(str(e)):  # BdbQuit
945             print("Exception: {0}".format(e.__class__.__name__))
946             sys.exit(2)
947
948         print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)
949         raise