5 # Copyright (c) 2015, Phillip Berndt
7 # Autorandr rewrite in Python
9 # This script aims to be fully compatible with the original autorandr.
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.
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.
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/>.
25 from __future__ import print_function
41 from collections import OrderedDict
42 from distutils.version import LooseVersion as Version
43 from functools import reduce
44 from itertools import chain
46 if sys.version_info.major == 2:
47 import ConfigParser as configparser
59 # (name, description, callback)
60 ("off", "Disable all outputs", None),
61 ("common", "Clone all connected outputs at the largest common resolution", None),
62 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
63 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
64 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
68 Usage: autorandr [options]
70 -h, --help get this small help
71 -c, --change automatically load the first detected profile
72 -d, --default <profile> make profile <profile> the default profile
73 -l, --load <profile> load profile <profile>
74 -s, --save <profile> save your current setup to profile <profile>
75 -r, --remove <profile> remove profile <profile>
76 --batch run autorandr for all users with active X11 sessions
77 --current only list current (active) configuration(s)
78 --config dump your current xrandr setup
79 --debug enable verbose output
80 --detected only list detected (available) configuration(s)
81 --dry-run don't change anything, only print the xrandr commands
82 --fingerprint fingerprint your current hardware setup
83 --force force (re)loading of a profile
84 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
85 to skip both in detecting changes and applying a profile
86 --version show version information and exit
88 If no suitable profile can be identified, the current configuration is kept.
89 To change this behaviour and switch to a fallback configuration, specify
92 autorandr supports a set of per-profile and global hooks. See the documentation
95 The following virtual configurations are available:
99 class AutorandrException(Exception):
100 def __init__(self, message, original_exception=None, report_bug=False):
101 self.message = message
102 self.report_bug = report_bug
103 if original_exception:
104 self.original_exception = original_exception
105 trace = sys.exc_info()[2]
107 trace = trace.tb_next
108 self.line = trace.tb_lineno
109 self.file_name = trace.tb_frame.f_code.co_filename
113 frame = inspect.currentframe().f_back
114 self.line = frame.f_lineno
115 self.file_name = frame.f_code.co_filename
118 self.file_name = None
119 self.original_exception = None
121 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
122 self.file_name = None
125 retval = [self.message]
127 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
128 if self.original_exception:
129 retval.append(":\n ")
130 retval.append(str(self.original_exception).replace("\n", "\n "))
132 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
133 "\nhttps://github.com/phillipberndt/autorandr/issues"
134 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
135 return "".join(retval)
138 class XrandrOutput(object):
139 "Represents an XRandR output"
141 # This regular expression is used to parse an output in `xrandr --verbose'
142 XRANDR_OUTPUT_REGEXP = """(?x)
143 ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
144 (?: # Differentiate disconnected and connected
145 disconnected | # in first line
146 unknown\ connection |
147 (?P<connected>connected)
150 (?P<primary>primary\ )? # Might be primary screen
152 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
153 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
154 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
155 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
156 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
157 )? # .. but only if the screen is in use.
158 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
159 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
160 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
161 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
162 (?:\s*(?: # Properties of the output
163 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
164 CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
165 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
166 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
167 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
171 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
172 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
173 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
174 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
178 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
179 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
180 h:\s+width\s+(?P<width>[0-9]+).+\s+
181 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
184 XRANDR_13_DEFAULTS = {
185 "transform": "1,0,0,0,1,0,0,0,1",
189 XRANDR_12_DEFAULTS = {
192 "gamma": "1.0:1.0:1.0",
195 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
197 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
200 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
203 def short_edid(self):
204 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
207 def options_with_defaults(self):
208 "Return the options dictionary, augmented with the default values that weren't set"
209 if "off" in self.options:
212 if xrandr_version() >= Version("1.3"):
213 options.update(self.XRANDR_13_DEFAULTS)
214 if xrandr_version() >= Version("1.2"):
215 options.update(self.XRANDR_12_DEFAULTS)
216 options.update(self.options)
217 return {a: b for a, b in options.items() if a not in self.ignored_options}
220 def filtered_options(self):
221 "Return a dictionary of options without ignored options"
222 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
225 def option_vector(self):
226 "Return the command line parameters for XRandR for this instance"
227 args = ["--output", self.output]
228 for option, arg in sorted(self.options_with_defaults.items()):
229 args.append("--%s" % option)
235 def option_string(self):
236 "Return the command line parameters in the configuration file format"
237 options = ["output %s" % self.output]
238 for option, arg in sorted(self.filtered_options.items()):
240 options.append("%s %s" % (option, arg))
242 options.append(option)
243 return "\n".join(options)
247 "Return a key to sort the outputs for xrandr invocation"
250 if "off" in self.options:
252 if "pos" in self.options:
253 x, y = map(float, self.options["pos"].split("x"))
258 def __init__(self, output, edid, options):
259 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
262 self.options = options
263 self.ignored_options = []
264 self.remove_default_option_values()
266 def set_ignored_options(self, options):
267 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
268 self.ignored_options = list(options)
270 def remove_default_option_values(self):
271 "Remove values from the options dictionary that are superflous"
272 if "off" in self.options and len(self.options.keys()) > 1:
273 self.options = {"off": None}
275 for option, default_value in self.XRANDR_DEFAULTS.items():
276 if option in self.options and self.options[option] == default_value:
277 del self.options[option]
280 def from_xrandr_output(cls, xrandr_output):
281 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
283 This method also returns a list of modes supported by the output.
286 xrandr_output = xrandr_output.replace("\r\n", "\n")
287 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
289 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
292 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
293 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
295 remainder = xrandr_output[len(match_object.group(0)):]
297 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
298 "regular expression, starting at byte %d with ..'%s'." %
299 (len(remainder), len(match_object.group(0)), remainder[:10]),
302 match = match_object.groupdict()
307 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
308 if mode_match.group("name"):
309 modes.append(mode_match.groupdict())
311 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
314 if not match["connected"]:
317 edid = "".join(match["edid"].strip().split())
319 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
321 if not match["connected"] or not match["width"]:
322 options["off"] = None
324 if match["mode_name"]:
325 options["mode"] = match["mode_name"]
326 elif match["mode_width"]:
327 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
329 if match["rotate"] not in ("left", "right"):
330 options["mode"] = "%sx%s" % (match["width"], match["height"])
332 options["mode"] = "%sx%s" % (match["height"], match["width"])
333 options["rotate"] = match["rotate"]
335 options["primary"] = None
336 if match["reflect"] == "X":
337 options["reflect"] = "x"
338 elif match["reflect"] == "Y":
339 options["reflect"] = "y"
340 elif match["reflect"] == "X and Y":
341 options["reflect"] = "xy"
342 if match["x"] or match["y"]:
343 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
345 panning = [match["panning"]]
346 if match["tracking"]:
347 panning += ["/", match["tracking"]]
349 panning += ["/", match["border"]]
350 options["panning"] = "".join(panning)
351 if match["transform"]:
352 transformation = ",".join(match["transform"].strip().split())
353 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
354 options["transform"] = transformation
355 if not match["mode_name"]:
356 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
357 # I doubt that this special case is actually required.
358 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
359 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
361 gamma = match["gamma"].strip()
362 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
363 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
364 # so we approximate by 1e-10.
365 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
366 options["gamma"] = gamma
368 options["crtc"] = match["crtc"]
370 options["rate"] = match["rate"]
372 return XrandrOutput(match["output"], edid, options), modes
375 def from_config_file(cls, edid_map, configuration):
376 "Instanciate an XrandrOutput from the contents of a configuration file"
378 for line in configuration.split("\n"):
380 line = line.split(None, 1)
381 if line and line[0].startswith("#"):
383 options[line[0]] = line[1] if len(line) > 1 else None
387 if options["output"] in edid_map:
388 edid = edid_map[options["output"]]
390 # This fuzzy matching is for legacy autorandr that used sysfs output names
391 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
392 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
393 if fuzzy_output in fuzzy_edid_map:
394 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
395 elif "off" not in options:
396 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
397 "is not off in config file." % (options["output"], options["output"]))
398 output = options["output"]
399 del options["output"]
401 return XrandrOutput(output, edid, options)
403 def edid_equals(self, other):
404 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
405 if self.edid and other.edid:
406 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
407 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
408 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
409 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
411 return fnmatch.fnmatch(other.edid, self.edid)
412 elif "*" in other.edid:
413 return fnmatch.fnmatch(self.edid, other.edid)
414 return self.edid == other.edid
416 def __ne__(self, other):
417 return not (self == other)
419 def __eq__(self, other):
420 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
422 def verbose_diff(self, other):
423 "Compare to another XrandrOutput and return a list of human readable differences"
425 if not self.edid_equals(other):
426 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
427 if self.output != other.output:
428 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
429 if "off" in self.options and "off" not in other.options:
430 diffs.append("The output is disabled currently, but active in the new configuration")
431 elif "off" in other.options and "off" not in self.options:
432 diffs.append("The output is currently enabled, but inactive in the new configuration")
434 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
435 if name not in other.options:
436 diffs.append("Option --%s %sis not present in the new configuration" %
437 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
438 elif name not in self.options:
439 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
440 (name, other.options[name]))
441 elif self.options[name] != other.options[name]:
442 diffs.append("Option --%s %sis `%s' in the new configuration" %
443 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
447 def xrandr_version():
448 "Return the version of XRandR that this system uses"
449 if getattr(xrandr_version, "version", False) is False:
450 version_string = os.popen("xrandr -v").read()
452 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
453 xrandr_version.version = Version(version)
454 except AttributeError:
455 xrandr_version.version = Version("1.3.0")
457 return xrandr_version.version
460 def debug_regexp(pattern, string):
461 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
464 bounds = (0, len(string))
465 while bounds[0] != bounds[1]:
466 half = int((bounds[0] + bounds[1]) / 2)
467 if half == bounds[0]:
469 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
470 partial_length = bounds[0]
471 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
472 (partial_length, string[max(0, partial_length - 20):partial_length],
473 string[partial_length:partial_length + 10]))
476 return "Debug information would be available if the `regex' module was installed."
479 def parse_xrandr_output():
480 "Parse the output of `xrandr --verbose' into a list of outputs"
481 xrandr_output = os.popen("xrandr -q --verbose").read()
482 if not xrandr_output:
483 raise AutorandrException("Failed to run xrandr")
485 # We are not interested in screens
486 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
488 # Split at output boundaries and instanciate an XrandrOutput per output
489 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
490 if len(split_xrandr_output) < 2:
491 raise AutorandrException("No output boundaries found", report_bug=True)
492 outputs = OrderedDict()
493 modes = OrderedDict()
494 for i in range(1, len(split_xrandr_output), 2):
495 output_name = split_xrandr_output[i].split()[0]
496 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
497 outputs[output_name] = output
499 modes[output_name] = output_modes
501 return outputs, modes
504 def load_profiles(profile_path):
505 "Load the stored profiles"
508 for profile in os.listdir(profile_path):
509 config_name = os.path.join(profile_path, profile, "config")
510 setup_name = os.path.join(profile_path, profile, "setup")
511 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
514 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
518 for line in chain(open(config_name).readlines(), ["output"]):
519 if line[:6] == "output" and buffer:
520 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
525 for output_name in list(config.keys()):
526 if config[output_name].edid is None:
527 del config[output_name]
529 profiles[profile] = {
531 "path": os.path.join(profile_path, profile),
532 "config-mtime": os.stat(config_name).st_mtime,
538 def get_symlinks(profile_path):
539 "Load all symlinks from a directory"
542 for link in os.listdir(profile_path):
543 file_name = os.path.join(profile_path, link)
544 if os.path.islink(file_name):
545 symlinks[link] = os.readlink(file_name)
550 def find_profiles(current_config, profiles):
551 "Find profiles matching the currently connected outputs"
552 detected_profiles = []
553 for profile_name, profile in profiles.items():
554 config = profile["config"]
556 for name, output in config.items():
559 if name not in current_config or not output.edid_equals(current_config[name]):
562 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
565 detected_profiles.append(profile_name)
566 return detected_profiles
569 def profile_blocked(profile_path, meta_information=None):
570 """Check if a profile is blocked.
572 meta_information is expected to be an dictionary. It will be passed to the block scripts
573 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
575 return not exec_scripts(profile_path, "block", meta_information)
578 def output_configuration(configuration, config):
579 "Write a configuration file"
580 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
581 for output in outputs:
582 print(configuration[output].option_string, file=config)
585 def output_setup(configuration, setup):
586 "Write a setup (fingerprint) file"
587 outputs = sorted(configuration.keys())
588 for output in outputs:
589 if configuration[output].edid:
590 print(output, configuration[output].edid, file=setup)
593 def save_configuration(profile_path, configuration):
594 "Save a configuration into a profile"
595 if not os.path.isdir(profile_path):
596 os.makedirs(profile_path)
597 with open(os.path.join(profile_path, "config"), "w") as config:
598 output_configuration(configuration, config)
599 with open(os.path.join(profile_path, "setup"), "w") as setup:
600 output_setup(configuration, setup)
603 def update_mtime(filename):
604 "Update a file's mtime"
606 os.utime(filename, None)
612 def call_and_retry(*args, **kwargs):
613 """Wrapper around subprocess.call that retries failed calls.
615 This function calls subprocess.call and on non-zero exit states,
616 waits a second and then retries once. This mitigates #47,
617 a timing issue with some drivers.
619 if "dry_run" in kwargs:
620 dry_run = kwargs["dry_run"]
621 del kwargs["dry_run"]
624 kwargs_redirected = dict(kwargs)
626 if hasattr(subprocess, "DEVNULL"):
627 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
629 kwargs_redirected["stdout"] = open(os.devnull, "w")
630 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
631 retval = subprocess.call(*args, **kwargs_redirected)
634 retval = subprocess.call(*args, **kwargs)
638 def get_fb_dimensions(configuration):
641 for output in configuration.values():
642 if "off" in output.options or not output.edid:
644 # This won't work with all modes -- but it's a best effort.
645 o_mode = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"]).group(0)
646 o_width, o_height = map(int, o_mode.split("x"))
647 if "transform" in output.options:
648 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
649 w = (g * o_width + h * o_height + i)
650 x = (a * o_width + b * o_height + c) / w
651 y = (d * o_width + e * o_height + f) / w
652 o_width, o_height = x, y
653 if "rotate" in output.options:
654 if output.options["rotate"] in ("left", "right"):
655 o_width, o_height = o_height, o_width
656 if "pos" in output.options:
657 o_left, o_top = map(int, output.options["pos"].split("x"))
660 if "panning" in output.options:
661 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
663 detail = match.groupdict(default="0")
664 o_width = int(detail.get("w")) + int(detail.get("x"))
665 o_height = int(detail.get("h")) + int(detail.get("y"))
666 width = max(width, o_width)
667 height = max(height, o_height)
668 return int(width), int(height)
671 def apply_configuration(new_configuration, current_configuration, dry_run=False):
672 "Apply a configuration"
673 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
675 base_argv = ["echo", "xrandr"]
677 base_argv = ["xrandr"]
679 # There are several xrandr / driver bugs we need to take care of here:
680 # - We cannot enable more than two screens at the same time
681 # See https://github.com/phillipberndt/autorandr/pull/6
682 # and commits f4cce4d and 8429886.
683 # - We cannot disable all screens
684 # See https://github.com/phillipberndt/autorandr/pull/20
685 # - We should disable screens before enabling others, because there's
686 # a limit on the number of enabled screens
687 # - We must make sure that the screen at 0x0 is activated first,
688 # or the other (first) screen to be activated would be moved there.
689 # - If an active screen already has a transformation and remains active,
690 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
691 # Update the configuration in 3 passes in that case. (On Haswell graphics,
693 # - Some implementations can not handle --transform at all, so avoid it unless
694 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
695 # - Some implementations can not handle --panning without specifying --fb
696 # explicitly, so avoid it unless necessary.
697 # (See https://github.com/phillipberndt/autorandr/issues/72)
699 fb_dimensions = get_fb_dimensions(new_configuration)
701 base_argv += ["--fb", "%dx%d" % fb_dimensions]
703 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
706 auxiliary_changes_pre = []
709 remain_active_count = 0
710 for output in outputs:
711 if not new_configuration[output].edid or "off" in new_configuration[output].options:
712 disable_outputs.append(new_configuration[output].option_vector)
714 if output not in current_configuration:
715 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
716 "Don't know how to proceed." % output)
717 if "off" not in current_configuration[output].options:
718 remain_active_count += 1
720 option_vector = new_configuration[output].option_vector
721 if xrandr_version() >= Version("1.3.0"):
722 for option, off_value in (("transform", "none"), ("panning", "0x0")):
723 if option in current_configuration[output].options:
724 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
727 option_index = option_vector.index("--%s" % option)
728 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
729 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
733 enable_outputs.append(option_vector)
735 # Perform pe-change auxiliary changes
736 if auxiliary_changes_pre:
737 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
738 if call_and_retry(argv, dry_run=dry_run) != 0:
739 raise AutorandrException("Command failed: %s" % " ".join(argv))
741 # Disable unused outputs, but make sure that there always is at least one active screen
742 disable_keep = 0 if remain_active_count else 1
743 if len(disable_outputs) > disable_keep:
744 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
745 if call_and_retry(argv, dry_run=dry_run) != 0:
746 # Disabling the outputs failed. Retry with the next command:
747 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
748 # This does not occur if simultaneously the primary screen is reset.
751 disable_outputs = disable_outputs[-1:] if disable_keep else []
753 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
754 # disable the last two screens. This is a problem, so if this would happen, instead disable only
755 # one screen in the first call below.
756 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
757 # In the context of a xrandr call that changes the display state, `--query' should do nothing
758 disable_outputs.insert(0, ['--query'])
760 # Enable the remaining outputs in pairs of two operations
761 operations = disable_outputs + enable_outputs
762 for index in range(0, len(operations), 2):
763 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
764 if call_and_retry(argv, dry_run=dry_run) != 0:
765 raise AutorandrException("Command failed: %s" % " ".join(argv))
768 def is_equal_configuration(source_configuration, target_configuration):
770 Check if all outputs from target are already configured correctly in source and
771 that no other outputs are active.
773 for output in target_configuration.keys():
774 if "off" in target_configuration[output].options:
775 if (output in source_configuration and "off" not in source_configuration[output].options):
778 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
780 for output in source_configuration.keys():
781 if "off" in source_configuration[output].options:
782 if output in target_configuration and "off" not in target_configuration[output].options:
785 if output not in target_configuration:
790 def add_unused_outputs(source_configuration, target_configuration):
791 "Add outputs that are missing in target to target, in 'off' state"
792 for output_name, output in source_configuration.items():
793 if output_name not in target_configuration:
794 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
797 def remove_irrelevant_outputs(source_configuration, target_configuration):
798 "Remove outputs from target that ought to be 'off' and already are"
799 for output_name, output in source_configuration.items():
800 if "off" in output.options:
801 if output_name in target_configuration:
802 if "off" in target_configuration[output_name].options:
803 del target_configuration[output_name]
806 def generate_virtual_profile(configuration, modes, profile_name):
807 "Generate one of the virtual profiles"
808 configuration = copy.deepcopy(configuration)
809 if profile_name == "common":
811 for output, output_modes in modes.items():
813 if configuration[output].edid:
814 for mode in output_modes:
815 mode_set.add((mode["width"], mode["height"]))
816 mode_sets.append(mode_set)
817 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
818 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
819 if common_resolution:
820 for output in configuration:
821 configuration[output].options = {}
822 if output in modes and configuration[output].edid:
823 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
824 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
825 mode = modes_filtered[0]
826 configuration[output].options["mode"] = mode['name']
827 configuration[output].options["pos"] = "0x0"
829 configuration[output].options["off"] = None
830 elif profile_name in ("horizontal", "vertical"):
832 if profile_name == "horizontal":
833 shift_index = "width"
834 pos_specifier = "%sx0"
836 shift_index = "height"
837 pos_specifier = "0x%s"
839 for output in configuration:
840 configuration[output].options = {}
841 if output in modes and configuration[output].edid:
843 score = int(a["width"]) * int(a["height"])
847 output_modes = sorted(modes[output], key=key)
848 mode = output_modes[-1]
849 configuration[output].options["mode"] = mode["name"]
850 configuration[output].options["rate"] = mode["rate"]
851 configuration[output].options["pos"] = pos_specifier % shift
852 shift += int(mode[shift_index])
854 configuration[output].options["off"] = None
855 elif profile_name == "clone-largest":
856 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
857 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
858 biggest_resolution = modes_sorted[0]
859 for output in configuration:
860 configuration[output].options = {}
861 if output in modes and configuration[output].edid:
863 score = int(a["width"]) * int(a["height"])
867 output_modes = sorted(modes[output], key=key)
868 mode = output_modes[-1]
869 configuration[output].options["mode"] = mode["name"]
870 configuration[output].options["rate"] = mode["rate"]
871 configuration[output].options["pos"] = "0x0"
872 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
873 float(biggest_resolution["height"]) / float(mode["height"]))
874 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
875 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
876 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
878 configuration[output].options["off"] = None
879 elif profile_name == "off":
880 for output in configuration:
881 for key in list(configuration[output].options.keys()):
882 del configuration[output].options[key]
883 configuration[output].options["off"] = None
887 def print_profile_differences(one, another):
888 "Print the differences between two profiles for debugging"
891 print("| Differences between the two profiles:")
892 for output in set(chain.from_iterable((one.keys(), another.keys()))):
893 if output not in one:
894 if "off" not in another[output].options:
895 print("| Output `%s' is missing from the active configuration" % output)
896 elif output not in another:
897 if "off" not in one[output].options:
898 print("| Output `%s' is missing from the new configuration" % output)
900 for line in one[output].verbose_diff(another[output]):
901 print("| [Output %s] %s" % (output, line))
906 "Print help and exit"
908 for profile in virtual_profiles:
909 name, description = profile[:2]
910 description = [description]
912 while len(description[0]) > max_width + 1:
913 left_over = description[0][max_width:]
914 description[0] = description[0][:max_width] + "-"
915 description.insert(1, " %-15s %s" % ("", left_over))
916 description = "\n".join(description)
917 print(" %-15s %s" % (name, description))
921 def exec_scripts(profile_path, script_name, meta_information=None):
924 This will run all executables from the profile folder, and global per-user
925 and system-wide configuration folders, named script_name or residing in
926 subdirectories named script_name.d.
928 If profile_path is None, only global scripts will be invoked.
930 meta_information is expected to be an dictionary. It will be passed to the block scripts
931 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
933 Returns True unless any of the scripts exited with non-zero exit status.
936 env = os.environ.copy()
938 for key, value in meta_information.items():
939 env["AUTORANDR_{}".format(key.upper())] = str(value)
941 # If there are multiple candidates, the XDG spec tells to only use the first one.
944 user_profile_path = os.path.expanduser("~/.autorandr")
945 if not os.path.isdir(user_profile_path):
946 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
948 candidate_directories = [user_profile_path]
949 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
950 candidate_directories.append(os.path.join(config_dir, "autorandr"))
952 candidate_directories.append(profile_path)
954 for folder in candidate_directories:
955 if script_name not in ran_scripts:
956 script = os.path.join(folder, script_name)
957 if os.access(script, os.X_OK | os.F_OK):
959 all_ok &= subprocess.call(script, env=env) != 0
961 raise AutorandrException("Failed to execute user command: %s" % (script,))
962 ran_scripts.add(script_name)
964 script_folder = os.path.join(folder, "%s.d" % script_name)
965 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
966 for file_name in os.listdir(script_folder):
967 check_name = "d/%s" % (file_name,)
968 if check_name not in ran_scripts:
969 script = os.path.join(script_folder, file_name)
970 if os.access(script, os.X_OK | os.F_OK):
972 all_ok &= subprocess.call(script, env=env) != 0
974 raise AutorandrException("Failed to execute user command: %s" % (script,))
975 ran_scripts.add(check_name)
980 def dispatch_call_to_sessions(argv):
981 """Invoke autorandr for each open local X11 session with the given options.
983 The function iterates over all processes not owned by root and checks
984 whether they have DISPLAY and XAUTHORITY variables set. It strips the
985 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
986 this display has been handled already. If it has not, it forks, changes
987 uid/gid to the user owning the process, reuses the process's environment
988 and runs autorandr with the parameters from argv.
990 This function requires root permissions. It only works for X11 servers that
991 have at least one non-root process running. It is susceptible for attacks
992 where one user runs a process with another user's DISPLAY variable - in
993 this case, it might happen that autorandr is invoked for the other user,
994 which won't work. Since no other harm than prevention of automated
995 execution of autorandr can be done this way, the assumption is that in this
996 situation, the local administrator will handle the situation."""
998 X11_displays_done = set()
1000 autorandr_binary = os.path.abspath(argv[0])
1001 backup_candidates = {}
1003 def fork_child_autorandr(pwent, process_environ):
1004 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1005 child_pid = os.fork()
1007 # This will throw an exception if any of the privilege changes fails,
1008 # so it should be safe. Also, note that since the environment
1009 # is taken from a process owned by the user, reusing it should
1010 # not leak any information.
1012 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1013 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1014 os.chdir(pwent.pw_dir)
1016 os.environ.update(process_environ)
1017 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1019 os.waitpid(child_pid, 0)
1021 for directory in os.listdir("/proc"):
1022 directory = os.path.join("/proc/", directory)
1023 if not os.path.isdir(directory):
1025 environ_file = os.path.join(directory, "environ")
1026 if not os.path.isfile(environ_file):
1028 uid = os.stat(environ_file).st_uid
1030 # The following line assumes that user accounts start at 1000 and that
1031 # no one works using the root or another system account. This is rather
1032 # restrictive, but de facto default. Alternatives would be to use the
1033 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1034 # but effectively, both values aren't binding in any way.
1035 # If this breaks your use case, please file a bug on Github.
1039 process_environ = {}
1040 for environ_entry in open(environ_file).read().split("\0"):
1041 name, sep, value = environ_entry.partition("=")
1043 if name == "DISPLAY" and "." in value:
1044 value = value[:value.find(".")]
1045 process_environ[name] = value
1047 if "DISPLAY" not in process_environ:
1048 # Cannot work with this environment, skip.
1051 # To allow scripts to detect batch invocation (especially useful for predetect)
1052 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1053 process_environ["UID"] = str(uid)
1055 display = process_environ["DISPLAY"]
1057 if "XAUTHORITY" not in process_environ:
1058 # It's very likely that we cannot work with this environment either,
1059 # but keep it as a backup just in case we don't find anything else.
1060 backup_candidates[display] = process_environ
1063 if display not in X11_displays_done:
1065 pwent = pwd.getpwuid(uid)
1067 # User has no pwd entry
1070 fork_child_autorandr(pwent, process_environ)
1071 X11_displays_done.add(display)
1073 # Run autorandr for any users/displays which didn't have a process with
1075 for display, process_environ in backup_candidates.items():
1076 if display not in X11_displays_done:
1078 pwent = pwd.getpwuid(int(process_environ["UID"]))
1080 # User has no pwd entry
1083 fork_child_autorandr(pwent, process_environ)
1084 X11_displays_done.add(display)
1087 def enabled_monitors(config):
1089 for monitor in config:
1090 if "--off" in config[monitor].option_vector:
1092 monitors.append(monitor)
1096 def read_config(options, directory):
1097 """Parse a configuration config.ini from directory and merge it into
1098 the options dictionary"""
1099 config = configparser.ConfigParser()
1100 config.read(os.path.join(directory, "settings.ini"))
1101 if config.has_section("config"):
1102 for key, value in config.items("config"):
1103 options.setdefault("--%s" % key, value)
1107 opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1108 ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
1109 "force", "fingerprint", "config", "debug", "skip-options=", "help",
1110 "current", "detected", "version"])
1111 except getopt.GetoptError as e:
1112 print("Failed to parse options: {0}.\n"
1113 "Use --help to get usage information.".format(str(e)),
1115 sys.exit(posix.EX_USAGE)
1117 options = dict(opts)
1119 if "-h" in options or "--help" in options:
1122 if "--version" in options:
1123 print("autorandr " + __version__)
1126 if "--current" in options and "--detected" in options:
1127 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1128 sys.exit(posix.EX_USAGE)
1131 if "--batch" in options:
1132 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1133 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1135 print("--batch mode can only be used by root and if $DISPLAY is unset")
1137 if "AUTORANDR_BATCH_PID" in os.environ:
1138 user = pwd.getpwuid(os.getuid())
1139 user = user.pw_name if user else "#%d" % os.getuid()
1140 print("autorandr running as user %s (started from batch instance)" % user)
1143 profile_symlinks = {}
1145 # Load profiles from each XDG config directory
1146 # The XDG spec says that earlier entries should take precedence, so reverse the order
1147 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1148 system_profile_path = os.path.join(directory, "autorandr")
1149 if os.path.isdir(system_profile_path):
1150 profiles.update(load_profiles(system_profile_path))
1151 profile_symlinks.update(get_symlinks(system_profile_path))
1152 read_config(options, system_profile_path)
1153 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1154 # profile_path is also used later on to store configurations
1155 profile_path = os.path.expanduser("~/.autorandr")
1156 if not os.path.isdir(profile_path):
1157 # Elsewise, follow the XDG specification
1158 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1159 if os.path.isdir(profile_path):
1160 profiles.update(load_profiles(profile_path))
1161 profile_symlinks.update(get_symlinks(profile_path))
1162 read_config(options, profile_path)
1163 # Sort by descending mtime
1164 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1165 except Exception as e:
1166 raise AutorandrException("Failed to load profiles", e)
1168 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}
1170 exec_scripts(None, "predetect")
1171 config, modes = parse_xrandr_output()
1173 if "--fingerprint" in options:
1174 output_setup(config, sys.stdout)
1177 if "--config" in options:
1178 output_configuration(config, sys.stdout)
1181 if "--skip-options" in options:
1182 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1183 for profile in profiles.values():
1184 for output in profile["config"].values():
1185 output.set_ignored_options(skip_options)
1186 for output in config.values():
1187 output.set_ignored_options(skip_options)
1190 options["--save"] = options["-s"]
1191 if "--save" in options:
1192 if options["--save"] in (x[0] for x in virtual_profiles):
1193 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1194 "This configuration name is a reserved virtual configuration." % options["--save"])
1196 profile_folder = os.path.join(profile_path, options["--save"])
1197 save_configuration(profile_folder, config)
1198 exec_scripts(profile_folder, "postsave", {
1199 "CURRENT_PROFILE": options["--save"],
1200 "PROFILE_FOLDER": profile_folder,
1201 "MONITORS": ":".join(enabled_monitors(config)),
1203 except Exception as e:
1204 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1205 print("Saved current configuration as profile '%s'" % options["--save"])
1209 options["--remove"] = options["-r"]
1210 if "--remove" in options:
1211 if options["--remove"] in (x[0] for x in virtual_profiles):
1212 raise AutorandrException("Cannot remove profile '%s':\n"
1213 "This configuration name is a reserved virtual configuration." % options["--remove"])
1214 if options["--remove"] not in profiles.keys():
1215 raise AutorandrException("Cannot remove profile '%s':\n"
1216 "This profile does not exist." % options["--remove"])
1219 profile_folder = os.path.join(profile_path, options["--remove"])
1220 profile_dirlist = os.listdir(profile_folder)
1221 profile_dirlist.remove("config")
1222 profile_dirlist.remove("setup")
1224 print("Profile folder '%s' contains the following additional files:\n"
1225 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1226 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1227 if response != "yes":
1230 shutil.rmtree(profile_folder)
1231 print("Removed profile '%s'" % options["--remove"])
1233 print("Profile '%s' was not removed" % options["--remove"])
1234 except Exception as e:
1235 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1238 detected_profiles = find_profiles(config, profiles)
1239 load_profile = False
1242 options["--load"] = options["-l"]
1243 if "--load" in options:
1244 load_profile = options["--load"]
1245 elif len(args) == 1:
1246 load_profile = args[0]
1248 # Find the active profile(s) first, for the block script (See #42)
1249 current_profiles = []
1250 for profile_name in profiles.keys():
1251 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1252 if configs_are_equal:
1253 current_profiles.append(profile_name)
1254 block_script_metadata = {
1255 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1256 "CURRENT_PROFILES": ":".join(current_profiles)
1259 for profile_name in profiles.keys():
1260 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1261 if "--current" not in options and "--detected" not in options:
1262 print("%s (blocked)" % profile_name)
1265 if profile_name in detected_profiles:
1266 props.append("(detected)")
1267 if ("-c" in options or "--change" in options) and not load_profile:
1268 load_profile = profile_name
1269 elif "--detected" in options:
1271 if profile_name in current_profiles:
1272 props.append("(current)")
1273 elif "--current" in options:
1275 if "--current" in options or "--detected" in options:
1276 print("%s" % (profile_name, ))
1278 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1279 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1280 print_profile_differences(config, profiles[profile_name]["config"])
1283 options["--default"] = options["-d"]
1284 if not load_profile and "--default" in options and ("-c" in options or "--change" in options):
1285 load_profile = options["--default"]
1288 if load_profile in profile_symlinks:
1289 if "--debug" in options:
1290 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1291 load_profile = profile_symlinks[load_profile]
1293 if load_profile in (x[0] for x in virtual_profiles):
1294 load_config = generate_virtual_profile(config, modes, load_profile)
1295 scripts_path = os.path.join(profile_path, load_profile)
1298 profile = profiles[load_profile]
1299 load_config = profile["config"]
1300 scripts_path = profile["path"]
1302 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1303 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1304 update_mtime(os.path.join(scripts_path, "config"))
1305 add_unused_outputs(config, load_config)
1306 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1307 print("Config already loaded", file=sys.stderr)
1309 if "--debug" in options and load_config != dict(config):
1310 print("Loading profile '%s'" % load_profile)
1311 print_profile_differences(config, load_config)
1313 remove_irrelevant_outputs(config, load_config)
1316 if "--dry-run" in options:
1317 apply_configuration(load_config, config, True)
1320 "CURRENT_PROFILE": load_profile,
1321 "PROFILE_FOLDER": scripts_path,
1322 "MONITORS": ":".join(enabled_monitors(load_config)),
1324 exec_scripts(scripts_path, "preswitch", script_metadata)
1325 if "--debug" in options:
1326 print("Going to run:")
1327 apply_configuration(load_config, config, True)
1328 apply_configuration(load_config, config, False)
1329 exec_scripts(scripts_path, "postswitch", script_metadata)
1330 except AutorandrException as e:
1331 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1332 except Exception as e:
1333 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1335 if "--dry-run" not in options and "--debug" in options:
1336 new_config, _ = parse_xrandr_output()
1337 if not is_equal_configuration(new_config, load_config):
1338 print("The configuration change did not go as expected:")
1339 print_profile_differences(new_config, load_config)
1344 def exception_handled_main(argv=sys.argv):
1347 except AutorandrException as e:
1348 print(e, file=sys.stderr)
1350 except Exception as e:
1351 if not len(str(e)): # BdbQuit
1352 print("Exception: {0}".format(e.__class__.__name__))
1355 print("Unhandled exception ({0}). Please report this as a bug at "
1356 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1361 if __name__ == '__main__':
1362 exception_handled_main()