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
43 from collections import OrderedDict
44 from functools import reduce
45 from itertools import chain
48 if sys.version_info.major == 2:
49 import ConfigParser as configparser
61 # (name, description, callback)
62 ("off", "Disable all outputs", None),
63 ("common", "Clone all connected outputs at the largest common resolution", None),
64 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
65 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
66 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
67 ("horizontal-reverse", "Stack all connected outputs horizontally at their largest resolution in reverse order", None),
68 ("vertical-reverse", "Stack all connected outputs vertically at their largest resolution in reverse order", None),
86 Usage: autorandr [options]
88 -h, --help get this small help
89 -c, --change automatically load the first detected profile
90 -d, --default <profile> make profile <profile> the default profile
91 -l, --load <profile> load profile <profile>
92 -s, --save <profile> save your current setup to profile <profile>
93 -r, --remove <profile> remove profile <profile>
94 --batch run autorandr for all users with active X11 sessions
95 --current only list current (active) configuration(s)
96 --config dump your current xrandr setup
97 --cycle automatically load the next detected profile
98 --debug enable verbose output
99 --detected only list detected (available) configuration(s)
100 --dry-run don't change anything, only print the xrandr commands
101 --fingerprint fingerprint your current hardware setup
102 --ignore-lid treat outputs as connected even if their lids are closed
103 --match-edid match diplays based on edid instead of name
104 --force force (re)loading of a profile / overwrite exiting files
105 --list list configurations
106 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
107 to skip both in detecting changes and applying a profile
108 --version show version information and exit
110 If no suitable profile can be identified, the current configuration is kept.
111 To change this behaviour and switch to a fallback configuration, specify
114 autorandr supports a set of per-profile and global hooks. See the documentation
117 The following virtual configurations are available:
121 class Version(object):
122 def __init__(self, version):
123 self._version = version
124 self._version_parts = re.split("([0-9]+)", version)
126 def __eq__(self, other):
127 return self._version_parts == other._version_parts
129 def __lt__(self, other):
130 for my, theirs in zip(self._version_parts, other._version_parts):
131 if my.isnumeric() and theirs.isnumeric():
136 return len(theirs) > len(my)
138 def __ge__(self, other):
139 return not (self < other)
141 def __ne__(self, other):
142 return not (self == other)
144 def __le__(self, other):
145 return (self < other) or (self == other)
147 def __gt__(self, other):
148 return self >= other and not (self == other)
150 def is_closed_lid(output):
151 if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
153 lids = glob.glob("/proc/acpi/button/lid/*/state")
156 with open(state_file) as f:
158 return "close" in content
162 class AutorandrException(Exception):
163 def __init__(self, message, original_exception=None, report_bug=False):
164 self.message = message
165 self.report_bug = report_bug
166 if original_exception:
167 self.original_exception = original_exception
168 trace = sys.exc_info()[2]
170 trace = trace.tb_next
171 self.line = trace.tb_lineno
172 self.file_name = trace.tb_frame.f_code.co_filename
176 frame = inspect.currentframe().f_back
177 self.line = frame.f_lineno
178 self.file_name = frame.f_code.co_filename
181 self.file_name = None
182 self.original_exception = None
184 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
185 self.file_name = None
188 retval = [self.message]
190 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
191 if self.original_exception:
192 retval.append(":\n ")
193 retval.append(str(self.original_exception).replace("\n", "\n "))
195 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
196 "\nhttps://github.com/phillipberndt/autorandr/issues"
197 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
198 return "".join(retval)
201 class XrandrOutput(object):
202 "Represents an XRandR output"
204 XRANDR_PROPERTIES_REGEXP = "|".join(
205 [r"{}:\s*(?P<{}>[\S ]*\S+)"
206 .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
207 for p in properties])
209 # This regular expression is used to parse an output in `xrandr --verbose'
210 XRANDR_OUTPUT_REGEXP = """(?x)
211 ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
212 (?: # Differentiate disconnected and connected
213 disconnected | # in first line
214 unknown\ connection |
215 (?P<connected>connected)
218 (?P<primary>primary\ )? # Might be primary screen
220 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
221 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
222 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
223 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
224 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
225 )? # .. but only if the screen is in use.
226 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
227 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
228 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
229 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
230 (?:\s*(?: # Properties of the output
231 Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
232 CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
233 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
234 filter:\s+(?P<filter>bilinear|nearest) | # Transformation filter
235 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
236 """ + XRANDR_PROPERTIES_REGEXP + """ | # Properties to include in the profile
237 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
241 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
242 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
243 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
244 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
248 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
249 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
250 h:\s+width\s+(?P<width>[0-9]+).+\s+
251 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
254 XRANDR_13_DEFAULTS = {
255 "transform": "1,0,0,0,1,0,0,0,1",
259 XRANDR_12_DEFAULTS = {
262 "gamma": "1.0:1.0:1.0",
265 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
267 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
270 return "<%s%s %s>" % (self.output, self.fingerprint, " ".join(self.option_vector))
273 def short_edid(self):
274 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
277 def options_with_defaults(self):
278 "Return the options dictionary, augmented with the default values that weren't set"
279 if "off" in self.options:
282 if xrandr_version() >= Version("1.3"):
283 options.update(self.XRANDR_13_DEFAULTS)
284 if xrandr_version() >= Version("1.2"):
285 options.update(self.XRANDR_12_DEFAULTS)
286 options.update(self.options)
287 if "set" in self.ignored_options:
288 options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
289 return {a: b for a, b in options.items() if a not in self.ignored_options}
292 def filtered_options(self):
293 "Return a dictionary of options without ignored options"
294 options = {a: b for a, b in self.options.items() if a not in self.ignored_options}
295 if "set" in self.ignored_options:
296 options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
300 def option_vector(self):
301 "Return the command line parameters for XRandR for this instance"
302 args = ["--output", self.output]
303 for option, arg in sorted(self.options_with_defaults.items()):
304 if option.startswith("x-prop-"):
306 for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
307 if prop == option[7:]:
309 args.append(xrandr_prop)
313 print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
315 elif option.startswith("x-"):
316 print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
319 args.append("--%s" % option)
325 def option_string(self):
326 "Return the command line parameters in the configuration file format"
327 options = ["output %s" % self.output]
328 for option, arg in sorted(self.filtered_options.items()):
330 options.append("%s %s" % (option, arg))
332 options.append(option)
333 return "\n".join(options)
337 "Return a key to sort the outputs for xrandr invocation"
340 if "off" in self.options:
342 if "pos" in self.options:
343 x, y = map(float, self.options["pos"].split("x"))
348 def __init__(self, output, edid, options):
349 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
352 self.options = options
353 self.ignored_options = []
354 self.parse_serial_from_edid()
355 self.remove_default_option_values()
357 def parse_serial_from_edid(self):
360 if self.EDID_UNAVAILABLE in self.edid:
362 # Thx to pyedid project, the following code was
363 # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
364 raw = bytes.fromhex(self.edid)
365 # Check EDID header, and checksum
366 if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
368 serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
371 # Offsets of standard timing information descriptors 1-4
372 # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
373 for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
374 if timing_bytes[0:2] == b'\x00\x00':
375 timing_type = timing_bytes[3]
376 if timing_type == 0xFF:
377 buffer = timing_bytes[5:]
378 buffer = buffer.partition(b'\x0a')[0]
379 serial_text = buffer.decode('cp437')
380 self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
382 def set_ignored_options(self, options):
383 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
384 self.ignored_options = list(options)
386 def remove_default_option_values(self):
387 "Remove values from the options dictionary that are superflous"
388 if "off" in self.options and len(self.options.keys()) > 1:
389 self.options = {"off": None}
391 for option, default_value in self.XRANDR_DEFAULTS.items():
392 if option in self.options and self.options[option] == default_value:
393 del self.options[option]
396 def from_xrandr_output(cls, xrandr_output):
397 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
399 This method also returns a list of modes supported by the output.
402 xrandr_output = xrandr_output.replace("\r\n", "\n")
403 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
405 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
408 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
409 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
411 remainder = xrandr_output[len(match_object.group(0)):]
413 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
414 "regular expression, starting at byte %d with ..'%s'." %
415 (len(remainder), len(match_object.group(0)), remainder[:10]),
418 match = match_object.groupdict()
423 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
424 if mode_match.group("name"):
425 modes.append(mode_match.groupdict())
427 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
430 if not match["connected"]:
433 edid = "".join(match["edid"].strip().split())
435 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
437 # An output can be disconnected but still have a mode configured. This can only happen
438 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
441 # This code needs to be careful not to mix the two. An output should only be configured to
442 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
443 if not match["width"]:
444 options["off"] = None
446 if match["mode_name"]:
447 options["mode"] = match["mode_name"]
448 elif match["mode_width"]:
449 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
451 if match["rotate"] not in ("left", "right"):
452 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
454 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
456 options["rotate"] = match["rotate"]
458 options["primary"] = None
459 if match["reflect"] == "X":
460 options["reflect"] = "x"
461 elif match["reflect"] == "Y":
462 options["reflect"] = "y"
463 elif match["reflect"] == "X and Y":
464 options["reflect"] = "xy"
465 if match["x"] or match["y"]:
466 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
468 panning = [match["panning"]]
469 if match["tracking"]:
470 panning += ["/", match["tracking"]]
472 panning += ["/", match["border"]]
473 options["panning"] = "".join(panning)
474 if match["transform"]:
475 transformation = ",".join(match["transform"].strip().split())
476 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
477 options["transform"] = transformation
478 if not match["mode_name"]:
479 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
480 # I doubt that this special case is actually required.
481 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
482 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
484 options["filter"] = match["filter"]
486 gamma = match["gamma"].strip()
487 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
488 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
489 # so we approximate by 1e-10.
490 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
491 options["gamma"] = gamma
493 options["crtc"] = match["crtc"]
495 options["rate"] = match["rate"]
496 for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
498 options["x-prop-" + prop] = match[prop]
500 return XrandrOutput(match["output"], edid, options), modes
503 def from_config_file(cls, profile, edid_map, configuration):
504 "Instanciate an XrandrOutput from the contents of a configuration file"
506 for line in configuration.split("\n"):
508 line = line.split(None, 1)
509 if line and line[0].startswith("#"):
511 options[line[0]] = line[1] if len(line) > 1 else None
515 if options["output"] in edid_map:
516 edid = edid_map[options["output"]]
518 # This fuzzy matching is for legacy autorandr that used sysfs output names
519 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
520 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
521 if fuzzy_output in fuzzy_edid_map:
522 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
523 elif "off" not in options:
524 raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
525 "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
526 output = options["output"]
527 del options["output"]
529 return XrandrOutput(output, edid, options)
532 def fingerprint(self):
533 return str(self.serial) if self.serial else self.short_edid
535 def fingerprint_equals(self, other):
536 if self.serial and other.serial:
537 return self.serial == other.serial
539 return self.edid_equals(other)
541 def edid_equals(self, other):
542 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
543 if self.edid and other.edid:
544 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
545 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
546 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
547 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
549 return match_asterisk(self.edid, other.edid) > 0
550 elif "*" in other.edid:
551 return match_asterisk(other.edid, self.edid) > 0
552 return self.edid == other.edid
554 def __ne__(self, other):
555 return not (self == other)
557 def __eq__(self, other):
558 return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
560 def verbose_diff(self, other):
561 "Compare to another XrandrOutput and return a list of human readable differences"
563 if not self.fingerprint_equals(other):
564 diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
565 if self.output != other.output:
566 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
567 if "off" in self.options and "off" not in other.options:
568 diffs.append("The output is disabled currently, but active in the new configuration")
569 elif "off" in other.options and "off" not in self.options:
570 diffs.append("The output is currently enabled, but inactive in the new configuration")
572 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
573 if name not in other.options:
574 diffs.append("Option --%s %sis not present in the new configuration" %
575 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
576 elif name not in self.options:
577 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
578 (name, other.options[name]))
579 elif self.options[name] != other.options[name]:
580 diffs.append("Option --%s %sis `%s' in the new configuration" %
581 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
585 def xrandr_version():
586 "Return the version of XRandR that this system uses"
587 if getattr(xrandr_version, "version", False) is False:
588 version_string = os.popen("xrandr -v").read()
590 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
591 xrandr_version.version = Version(version)
592 except AttributeError:
593 xrandr_version.version = Version("1.3.0")
595 return xrandr_version.version
598 def debug_regexp(pattern, string):
599 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
602 bounds = (0, len(string))
603 while bounds[0] != bounds[1]:
604 half = int((bounds[0] + bounds[1]) / 2)
605 if half == bounds[0]:
607 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
608 partial_length = bounds[0]
609 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
610 (partial_length, string[max(0, partial_length - 20):partial_length],
611 string[partial_length:partial_length + 10]))
614 return "Debug information would be available if the `regex' module was installed."
617 def parse_xrandr_output(
621 "Parse the output of `xrandr --verbose' into a list of outputs"
622 xrandr_output = os.popen("xrandr -q --verbose").read()
623 if not xrandr_output:
624 raise AutorandrException("Failed to run xrandr")
626 # We are not interested in screens
627 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
629 # Split at output boundaries and instanciate an XrandrOutput per output
630 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
631 if len(split_xrandr_output) < 2:
632 raise AutorandrException("No output boundaries found", report_bug=True)
633 outputs = OrderedDict()
634 modes = OrderedDict()
635 for i in range(1, len(split_xrandr_output), 2):
636 output_name = split_xrandr_output[i].split()[0]
637 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
638 outputs[output_name] = output
640 modes[output_name] = output_modes
642 # consider a closed lid as disconnected if other outputs are connected
643 if not ignore_lid and sum(
648 for output_name in outputs.keys():
649 if is_closed_lid(output_name):
650 outputs[output_name].edid = None
652 return outputs, modes
655 def load_profiles(profile_path):
656 "Load the stored profiles"
659 for profile in os.listdir(profile_path):
660 config_name = os.path.join(profile_path, profile, "config")
661 setup_name = os.path.join(profile_path, profile, "setup")
662 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
665 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
669 for line in chain(open(config_name).readlines(), ["output"]):
670 if line[:6] == "output" and buffer:
671 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
676 for output_name in list(config.keys()):
677 if config[output_name].edid is None:
678 del config[output_name]
680 profiles[profile] = {
682 "path": os.path.join(profile_path, profile),
683 "config-mtime": os.stat(config_name).st_mtime,
689 def get_symlinks(profile_path):
690 "Load all symlinks from a directory"
693 for link in os.listdir(profile_path):
694 file_name = os.path.join(profile_path, link)
695 if os.path.islink(file_name):
696 symlinks[link] = os.readlink(file_name)
701 def match_asterisk(pattern, data):
702 """Match data against a pattern
704 The difference to fnmatch is that this function only accepts patterns with a single
705 asterisk and that it returns a "closeness" number, which is larger the better the match.
706 Zero indicates no match at all.
708 if "*" not in pattern:
709 return 1 if pattern == data else 0
710 parts = pattern.split("*")
712 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
713 if not data.startswith(parts[0]):
715 if not data.endswith(parts[1]):
717 matched = len(pattern)
718 total = len(data) + 1
719 return matched * 1. / total
722 def update_profiles_edid(profiles, config):
725 if config[c].fingerprint is not None:
726 fp_map[config[c].fingerprint] = c
729 profile_config = profiles[p]["config"]
731 for fingerprint in fp_map:
732 for c in list(profile_config.keys()):
733 if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
736 print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]))
738 tmp_disp = profile_config[c]
740 if fp_map[fingerprint] in profile_config:
741 # Swap the two entries
742 profile_config[c] = profile_config[fp_map[fingerprint]]
743 profile_config[c].output = c
745 # Object is reassigned to another key, drop this one
746 del profile_config[c]
748 profile_config[fp_map[fingerprint]] = tmp_disp
749 profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
752 def find_profiles(current_config, profiles):
753 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
754 detected_profiles = []
755 for profile_name, profile in profiles.items():
756 config = profile["config"]
758 for name, output in config.items():
759 if not output.fingerprint:
761 if name not in current_config or not output.fingerprint_equals(current_config[name]):
764 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
767 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
768 current_config[name].edid, output.edid))
769 detected_profiles.append((closeness, profile_name))
770 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
771 return detected_profiles
774 def profile_blocked(profile_path, meta_information=None):
775 """Check if a profile is blocked.
777 meta_information is expected to be an dictionary. It will be passed to the block scripts
778 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
780 return not exec_scripts(profile_path, "block", meta_information)
783 def check_configuration_pre_save(configuration):
784 "Check that a configuration is safe for saving."
785 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
786 for output in outputs:
787 if "off" not in configuration[output].options and not configuration[output].edid:
788 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
789 "This typically means that it has been recently unplugged and then not properly disabled\n"
790 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
791 "this command.") % {"o": output}
794 def output_configuration(configuration, config):
795 "Write a configuration file"
796 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
797 for output in outputs:
798 print(configuration[output].option_string, file=config)
801 def output_setup(configuration, setup):
802 "Write a setup (fingerprint) file"
803 outputs = sorted(configuration.keys())
804 for output in outputs:
805 if configuration[output].edid:
806 print(output, configuration[output].edid, file=setup)
809 def save_configuration(profile_path, profile_name, configuration, forced=False):
810 "Save a configuration into a profile"
811 if not os.path.isdir(profile_path):
812 os.makedirs(profile_path)
813 config_path = os.path.join(profile_path, "config")
814 setup_path = os.path.join(profile_path, "setup")
815 if os.path.isfile(config_path) and not forced:
816 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
817 if os.path.isfile(setup_path) and not forced:
818 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
820 with open(config_path, "w") as config:
821 output_configuration(configuration, config)
822 with open(setup_path, "w") as setup:
823 output_setup(configuration, setup)
826 def update_mtime(filename):
827 "Update a file's mtime"
829 os.utime(filename, None)
835 def call_and_retry(*args, **kwargs):
836 """Wrapper around subprocess.call that retries failed calls.
838 This function calls subprocess.call and on non-zero exit states,
839 waits a second and then retries once. This mitigates #47,
840 a timing issue with some drivers.
842 if kwargs.pop("dry_run", False):
844 print(shlex.quote(arg), end=" ")
848 if hasattr(subprocess, "DEVNULL"):
849 kwargs["stdout"] = getattr(subprocess, "DEVNULL")
851 kwargs["stdout"] = open(os.devnull, "w")
852 kwargs["stderr"] = kwargs["stdout"]
853 retval = subprocess.call(*args, **kwargs)
856 retval = subprocess.call(*args, **kwargs)
860 def get_fb_dimensions(configuration):
863 for output in configuration.values():
864 if "off" in output.options or not output.edid:
866 # This won't work with all modes -- but it's a best effort.
867 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
870 o_mode = match.group(0)
871 o_width, o_height = map(int, o_mode.split("x"))
872 if "transform" in output.options:
873 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
874 w = (g * o_width + h * o_height + i)
875 x = (a * o_width + b * o_height + c) / w
876 y = (d * o_width + e * o_height + f) / w
877 o_width, o_height = x, y
878 if "rotate" in output.options:
879 if output.options["rotate"] in ("left", "right"):
880 o_width, o_height = o_height, o_width
881 if "pos" in output.options:
882 o_left, o_top = map(int, output.options["pos"].split("x"))
885 if "panning" in output.options:
886 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
888 detail = match.groupdict(default="0")
889 o_width = int(detail.get("w")) + int(detail.get("x"))
890 o_height = int(detail.get("h")) + int(detail.get("y"))
891 width = max(width, o_width)
892 height = max(height, o_height)
893 return math.ceil(width), math.ceil(height)
896 def apply_configuration(new_configuration, current_configuration, dry_run=False):
897 "Apply a configuration"
898 found_top_left_monitor = False
899 found_left_monitor = False
900 found_top_monitor = False
901 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
902 base_argv = ["xrandr"]
904 # There are several xrandr / driver bugs we need to take care of here:
905 # - We cannot enable more than two screens at the same time
906 # See https://github.com/phillipberndt/autorandr/pull/6
907 # and commits f4cce4d and 8429886.
908 # - We cannot disable all screens
909 # See https://github.com/phillipberndt/autorandr/pull/20
910 # - We should disable screens before enabling others, because there's
911 # a limit on the number of enabled screens
912 # - We must make sure that the screen at 0x0 is activated first,
913 # or the other (first) screen to be activated would be moved there.
914 # - If an active screen already has a transformation and remains active,
915 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
916 # Update the configuration in 3 passes in that case. (On Haswell graphics,
918 # - Some implementations can not handle --transform at all, so avoid it unless
919 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
920 # - Some implementations can not handle --panning without specifying --fb
921 # explicitly, so avoid it unless necessary.
922 # (See https://github.com/phillipberndt/autorandr/issues/72)
924 fb_dimensions = get_fb_dimensions(new_configuration)
926 fb_args = ["--fb", "%dx%d" % fb_dimensions]
928 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
931 auxiliary_changes_pre = []
934 remain_active_count = 0
935 for output in outputs:
936 if not new_configuration[output].edid or "off" in new_configuration[output].options:
937 disable_outputs.append(new_configuration[output].option_vector)
939 if output not in current_configuration:
940 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
941 "Don't know how to proceed." % output)
942 if "off" not in current_configuration[output].options:
943 remain_active_count += 1
945 option_vector = new_configuration[output].option_vector
946 if xrandr_version() >= Version("1.3.0"):
947 for option, off_value in (("transform", "none"), ("panning", "0x0")):
948 if option in current_configuration[output].options:
949 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
952 option_index = option_vector.index("--%s" % option)
953 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
954 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
957 if not found_top_left_monitor:
958 position = new_configuration[output].options.get("pos", "0x0")
959 if position == "0x0":
960 found_top_left_monitor = True
961 enable_outputs.insert(0, option_vector)
962 elif not found_left_monitor and position.startswith("0x"):
963 found_left_monitor = True
964 enable_outputs.insert(0, option_vector)
965 elif not found_top_monitor and position.endswith("x0"):
966 found_top_monitor = True
967 enable_outputs.insert(0, option_vector)
969 enable_outputs.append(option_vector)
971 enable_outputs.append(option_vector)
973 # Perform pe-change auxiliary changes
974 if auxiliary_changes_pre:
975 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
976 if call_and_retry(argv, dry_run=dry_run) != 0:
977 raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
979 # Starting here, fix the frame buffer size
980 # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
981 # dimensions larger than they will finally be.
984 # Disable unused outputs, but make sure that there always is at least one active screen
985 disable_keep = 0 if remain_active_count else 1
986 if len(disable_outputs) > disable_keep:
987 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
988 if call_and_retry(argv, dry_run=dry_run) != 0:
989 # Disabling the outputs failed. Retry with the next command:
990 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
991 # This does not occur if simultaneously the primary screen is reset.
994 disable_outputs = disable_outputs[-1:] if disable_keep else []
996 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
997 # disable the last two screens. This is a problem, so if this would happen, instead disable only
998 # one screen in the first call below.
999 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
1000 # In the context of a xrandr call that changes the display state, `--query' should do nothing
1001 disable_outputs.insert(0, ['--query'])
1003 # If we did not find a candidate, we might need to inject a call
1004 # If there is no output to disable, we will enable 0x and x0 at the same time
1005 if not found_top_left_monitor and len(disable_outputs) > 0:
1006 # If the call to 0x and x0 is splitted, inject one of them
1007 if found_top_monitor and found_left_monitor:
1008 enable_outputs.insert(0, enable_outputs[0])
1010 # Enable the remaining outputs in pairs of two operations
1011 operations = disable_outputs + enable_outputs
1012 for index in range(0, len(operations), 2):
1013 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
1014 if call_and_retry(argv, dry_run=dry_run) != 0:
1015 raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
1018 def is_equal_configuration(source_configuration, target_configuration):
1020 Check if all outputs from target are already configured correctly in source and
1021 that no other outputs are active.
1023 for output in target_configuration.keys():
1024 if "off" in target_configuration[output].options:
1025 if (output in source_configuration and "off" not in source_configuration[output].options):
1028 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
1030 for output in source_configuration.keys():
1031 if "off" in source_configuration[output].options:
1032 if output in target_configuration and "off" not in target_configuration[output].options:
1035 if output not in target_configuration:
1040 def add_unused_outputs(source_configuration, target_configuration):
1041 "Add outputs that are missing in target to target, in 'off' state"
1042 for output_name, output in source_configuration.items():
1043 if output_name not in target_configuration:
1044 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1047 def remove_irrelevant_outputs(source_configuration, target_configuration):
1048 "Remove outputs from target that ought to be 'off' and already are"
1049 for output_name, output in source_configuration.items():
1050 if "off" in output.options:
1051 if output_name in target_configuration:
1052 if "off" in target_configuration[output_name].options:
1053 del target_configuration[output_name]
1056 def generate_virtual_profile(configuration, modes, profile_name):
1057 "Generate one of the virtual profiles"
1058 configuration = copy.deepcopy(configuration)
1059 if profile_name == "common":
1061 for output, output_modes in modes.items():
1063 if configuration[output].edid:
1064 for mode in output_modes:
1065 mode_set.add((mode["width"], mode["height"]))
1066 mode_sets.append(mode_set)
1067 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1068 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1069 if common_resolution:
1070 for output in configuration:
1071 configuration[output].options = {}
1072 if output in modes and configuration[output].edid:
1073 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1074 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1075 mode = modes_filtered[0]
1076 configuration[output].options["mode"] = mode['name']
1077 configuration[output].options["pos"] = "0x0"
1079 configuration[output].options["off"] = None
1080 elif profile_name in ("horizontal", "vertical", "horizontal-reverse", "vertical-reverse"):
1082 if profile_name.startswith("horizontal"):
1083 shift_index = "width"
1084 pos_specifier = "%sx0"
1086 shift_index = "height"
1087 pos_specifier = "0x%s"
1089 config_iter = reversed(configuration) if "reverse" in profile_name else iter(configuration)
1091 for output in config_iter:
1092 configuration[output].options = {}
1093 if output in modes and configuration[output].edid:
1095 score = int(a["width"]) * int(a["height"])
1099 output_modes = sorted(modes[output], key=key)
1100 mode = output_modes[-1]
1101 configuration[output].options["mode"] = mode["name"]
1102 configuration[output].options["rate"] = mode["rate"]
1103 configuration[output].options["pos"] = pos_specifier % shift
1104 shift += int(mode[shift_index])
1106 configuration[output].options["off"] = None
1107 elif profile_name == "clone-largest":
1108 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1109 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1110 biggest_resolution = modes_sorted[0]
1111 for output in configuration:
1112 configuration[output].options = {}
1113 if output in modes and configuration[output].edid:
1115 score = int(a["width"]) * int(a["height"])
1119 output_modes = sorted(modes[output], key=key)
1120 mode = output_modes[-1]
1121 configuration[output].options["mode"] = mode["name"]
1122 configuration[output].options["rate"] = mode["rate"]
1123 configuration[output].options["pos"] = "0x0"
1124 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1125 float(biggest_resolution["height"]) / float(mode["height"]))
1126 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1127 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1128 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1130 configuration[output].options["off"] = None
1131 elif profile_name == "off":
1132 for output in configuration:
1133 for key in list(configuration[output].options.keys()):
1134 del configuration[output].options[key]
1135 configuration[output].options["off"] = None
1136 return configuration
1139 def print_profile_differences(one, another):
1140 "Print the differences between two profiles for debugging"
1143 print("| Differences between the two profiles:")
1144 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1145 if output not in one:
1146 if "off" not in another[output].options:
1147 print("| Output `%s' is missing from the active configuration" % output)
1148 elif output not in another:
1149 if "off" not in one[output].options:
1150 print("| Output `%s' is missing from the new configuration" % output)
1152 for line in one[output].verbose_diff(another[output]):
1153 print("| [Output %s] %s" % (output, line))
1158 "Print help and exit"
1160 for profile in virtual_profiles:
1161 name, description = profile[:2]
1162 description = [description]
1164 while len(description[0]) > max_width + 1:
1165 left_over = description[0][max_width:]
1166 description[0] = description[0][:max_width] + "-"
1167 description.insert(1, " %-15s %s" % ("", left_over))
1168 description = "\n".join(description)
1169 print(" %-15s %s" % (name, description))
1173 def exec_scripts(profile_path, script_name, meta_information=None):
1176 This will run all executables from the profile folder, and global per-user
1177 and system-wide configuration folders, named script_name or residing in
1178 subdirectories named script_name.d.
1180 If profile_path is None, only global scripts will be invoked.
1182 meta_information is expected to be an dictionary. It will be passed to the block scripts
1183 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1185 Returns True unless any of the scripts exited with non-zero exit status.
1188 env = os.environ.copy()
1189 if meta_information:
1190 for key, value in meta_information.items():
1191 env["AUTORANDR_{}".format(key.upper())] = str(value)
1193 # If there are multiple candidates, the XDG spec tells to only use the first one.
1196 user_profile_path = os.path.expanduser("~/.autorandr")
1197 if not os.path.isdir(user_profile_path):
1198 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1200 candidate_directories = []
1202 candidate_directories.append(profile_path)
1203 candidate_directories.append(user_profile_path)
1204 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1205 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1207 for folder in candidate_directories:
1208 if script_name not in ran_scripts:
1209 script = os.path.join(folder, script_name)
1210 if os.access(script, os.X_OK | os.F_OK):
1212 all_ok &= subprocess.call(script, env=env) != 0
1213 except Exception as e:
1214 raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
1215 ran_scripts.add(script_name)
1217 script_folder = os.path.join(folder, "%s.d" % script_name)
1218 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1219 for file_name in sorted(os.listdir(script_folder)):
1220 check_name = "d/%s" % (file_name,)
1221 if check_name not in ran_scripts:
1222 script = os.path.join(script_folder, file_name)
1223 if os.access(script, os.X_OK | os.F_OK):
1225 all_ok &= subprocess.call(script, env=env) != 0
1226 except Exception as e:
1227 raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
1228 ran_scripts.add(check_name)
1233 def dispatch_call_to_sessions(argv):
1234 """Invoke autorandr for each open local X11 session with the given options.
1236 The function iterates over all processes not owned by root and checks
1237 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1238 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1239 this display has been handled already. If it has not, it forks, changes
1240 uid/gid to the user owning the process, reuses the process's environment
1241 and runs autorandr with the parameters from argv.
1243 This function requires root permissions. It only works for X11 servers that
1244 have at least one non-root process running. It is susceptible for attacks
1245 where one user runs a process with another user's DISPLAY variable - in
1246 this case, it might happen that autorandr is invoked for the other user,
1247 which won't work. Since no other harm than prevention of automated
1248 execution of autorandr can be done this way, the assumption is that in this
1249 situation, the local administrator will handle the situation."""
1251 X11_displays_done = set()
1253 autorandr_binary = os.path.abspath(argv[0])
1254 backup_candidates = {}
1256 def fork_child_autorandr(pwent, process_environ):
1257 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1258 child_pid = os.fork()
1260 # This will throw an exception if any of the privilege changes fails,
1261 # so it should be safe. Also, note that since the environment
1262 # is taken from a process owned by the user, reusing it should
1263 # not leak any information.
1265 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1266 except AttributeError:
1267 # Python 2 doesn't have getgrouplist
1269 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1270 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1271 os.chdir(pwent.pw_dir)
1273 os.environ.update(process_environ)
1274 if sys.executable != "" and sys.executable != None:
1275 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1277 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1279 os.waitpid(child_pid, 0)
1281 # The following line assumes that user accounts start at 1000 and that no
1282 # one works using the root or another system account. This is rather
1283 # restrictive, but de facto default. If this breaks your use case, set the
1284 # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1285 # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1286 # effectively, both values aren't binding in any way.)
1288 if 'AUTORANDR_UID_MIN' in os.environ:
1289 uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1291 for directory in os.listdir("/proc"):
1292 directory = os.path.join("/proc/", directory)
1293 if not os.path.isdir(directory):
1295 environ_file = os.path.join(directory, "environ")
1296 if not os.path.isfile(environ_file):
1298 uid = os.stat(environ_file).st_uid
1303 process_environ = {}
1304 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1306 environ_entry = environ_entry.decode("ascii")
1307 except UnicodeDecodeError:
1309 name, sep, value = environ_entry.partition("=")
1311 if name == "DISPLAY" and "." in value:
1312 value = value[:value.find(".")]
1313 process_environ[name] = value
1315 if "DISPLAY" not in process_environ:
1316 # Cannot work with this environment, skip.
1319 # To allow scripts to detect batch invocation (especially useful for predetect)
1320 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1321 process_environ["UID"] = str(uid)
1323 display = process_environ["DISPLAY"]
1325 if "XAUTHORITY" not in process_environ:
1326 # It's very likely that we cannot work with this environment either,
1327 # but keep it as a backup just in case we don't find anything else.
1328 backup_candidates[display] = process_environ
1331 if display not in X11_displays_done:
1333 pwent = pwd.getpwuid(uid)
1335 # User has no pwd entry
1338 fork_child_autorandr(pwent, process_environ)
1339 X11_displays_done.add(display)
1341 # Run autorandr for any users/displays which didn't have a process with
1343 for display, process_environ in backup_candidates.items():
1344 if display not in X11_displays_done:
1346 pwent = pwd.getpwuid(int(process_environ["UID"]))
1348 # User has no pwd entry
1351 fork_child_autorandr(pwent, process_environ)
1352 X11_displays_done.add(display)
1355 def enabled_monitors(config):
1357 for monitor in config:
1358 if "--off" in config[monitor].option_vector:
1360 monitors.append(monitor)
1364 def read_config(options, directory):
1365 """Parse a configuration config.ini from directory and merge it into
1366 the options dictionary"""
1367 config = configparser.ConfigParser()
1368 config.read(os.path.join(directory, "settings.ini"))
1369 if config.has_section("config"):
1370 for key, value in config.items("config"):
1371 options.setdefault("--%s" % key, value)
1375 opts, args = getopt.getopt(
1401 except getopt.GetoptError as e:
1402 print("Failed to parse options: {0}.\n"
1403 "Use --help to get usage information.".format(str(e)),
1405 sys.exit(posix.EX_USAGE)
1407 options = dict(opts)
1409 if "-h" in options or "--help" in options:
1412 if "--version" in options:
1413 print("autorandr " + __version__)
1416 if "--current" in options and "--detected" in options:
1417 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1418 sys.exit(posix.EX_USAGE)
1421 if "--batch" in options:
1422 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1423 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1425 print("--batch mode can only be used by root and if $DISPLAY is unset")
1427 if "AUTORANDR_BATCH_PID" in os.environ:
1428 user = pwd.getpwuid(os.getuid())
1429 user = user.pw_name if user else "#%d" % os.getuid()
1430 print("autorandr running as user %s (started from batch instance)" % user)
1433 profile_symlinks = {}
1435 # Load profiles from each XDG config directory
1436 # The XDG spec says that earlier entries should take precedence, so reverse the order
1437 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1438 system_profile_path = os.path.join(directory, "autorandr")
1439 if os.path.isdir(system_profile_path):
1440 profiles.update(load_profiles(system_profile_path))
1441 profile_symlinks.update(get_symlinks(system_profile_path))
1442 read_config(options, system_profile_path)
1443 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1444 # profile_path is also used later on to store configurations
1445 profile_path = os.path.expanduser("~/.autorandr")
1446 if not os.path.isdir(profile_path):
1447 # Elsewise, follow the XDG specification
1448 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1449 if os.path.isdir(profile_path):
1450 profiles.update(load_profiles(profile_path))
1451 profile_symlinks.update(get_symlinks(profile_path))
1452 read_config(options, profile_path)
1453 except Exception as e:
1454 raise AutorandrException("Failed to load profiles", e)
1456 exec_scripts(None, "predetect")
1458 ignore_lid = "--ignore-lid" in options
1460 config, modes = parse_xrandr_output(
1461 ignore_lid=ignore_lid,
1464 if "--match-edid" in options:
1465 update_profiles_edid(profiles, config)
1469 if "--cycle" in options:
1470 # When cycling through profiles, put the profile least recently used to the top of the list
1472 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1473 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}
1475 if "--fingerprint" in options:
1476 output_setup(config, sys.stdout)
1479 if "--config" in options:
1480 output_configuration(config, sys.stdout)
1483 if "--skip-options" in options:
1484 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1485 for profile in profiles.values():
1486 for output in profile["config"].values():
1487 output.set_ignored_options(skip_options)
1488 for output in config.values():
1489 output.set_ignored_options(skip_options)
1492 options["--save"] = options["-s"]
1493 if "--save" in options:
1494 if options["--save"] in (x[0] for x in virtual_profiles):
1495 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1496 "This configuration name is a reserved virtual configuration." % options["--save"])
1497 error = check_configuration_pre_save(config)
1499 print("Cannot save current configuration as profile '%s':" % options["--save"])
1503 profile_folder = os.path.join(profile_path, options["--save"])
1504 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1505 exec_scripts(profile_folder, "postsave", {
1506 "CURRENT_PROFILE": options["--save"],
1507 "PROFILE_FOLDER": profile_folder,
1508 "MONITORS": ":".join(enabled_monitors(config)),
1510 except AutorandrException as e:
1512 except Exception as e:
1513 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1514 print("Saved current configuration as profile '%s'" % options["--save"])
1518 options["--remove"] = options["-r"]
1519 if "--remove" in options:
1520 if options["--remove"] in (x[0] for x in virtual_profiles):
1521 raise AutorandrException("Cannot remove profile '%s':\n"
1522 "This configuration name is a reserved virtual configuration." % options["--remove"])
1523 if options["--remove"] not in profiles.keys():
1524 raise AutorandrException("Cannot remove profile '%s':\n"
1525 "This profile does not exist." % options["--remove"])
1528 profile_folder = os.path.join(profile_path, options["--remove"])
1529 profile_dirlist = os.listdir(profile_folder)
1530 profile_dirlist.remove("config")
1531 profile_dirlist.remove("setup")
1533 print("Profile folder '%s' contains the following additional files:\n"
1534 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1535 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1536 if response != "yes":
1539 shutil.rmtree(profile_folder)
1540 print("Removed profile '%s'" % options["--remove"])
1542 print("Profile '%s' was not removed" % options["--remove"])
1543 except Exception as e:
1544 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1547 detected_profiles = find_profiles(config, profiles)
1548 load_profile = False
1551 options["--load"] = options["-l"]
1552 if "--load" in options:
1553 load_profile = options["--load"]
1554 elif len(args) == 1:
1555 load_profile = args[0]
1557 # Find the active profile(s) first, for the block script (See #42)
1558 current_profiles = []
1559 for profile_name in profiles.keys():
1560 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1561 if configs_are_equal:
1562 current_profiles.append(profile_name)
1563 block_script_metadata = {
1564 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1565 "CURRENT_PROFILES": ":".join(current_profiles)
1569 for profile_name in profiles.keys():
1570 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1571 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1572 print("%s (blocked)" % profile_name)
1575 is_current_profile = profile_name in current_profiles
1576 if profile_name in detected_profiles:
1577 if len(detected_profiles) == 1:
1579 props.append("(detected)")
1581 index = detected_profiles.index(profile_name) + 1
1582 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1583 if index < best_index:
1584 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1585 load_profile = profile_name
1587 elif "--detected" in options:
1589 if is_current_profile:
1590 props.append("(current)")
1591 elif "--current" in options:
1593 if any(opt in options for opt in ("--current", "--detected", "--list")):
1594 print("%s" % (profile_name, ))
1596 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1597 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1598 print_profile_differences(config, profiles[profile_name]["config"])
1601 options["--default"] = options["-d"]
1602 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1603 load_profile = options["--default"]
1606 if load_profile in profile_symlinks:
1607 if "--debug" in options:
1608 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1609 load_profile = profile_symlinks[load_profile]
1611 if load_profile in (x[0] for x in virtual_profiles):
1612 load_config = generate_virtual_profile(config, modes, load_profile)
1613 scripts_path = os.path.join(profile_path, load_profile)
1616 profile = profiles[load_profile]
1617 load_config = profile["config"]
1618 scripts_path = profile["path"]
1620 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1621 if "--dry-run" not in options:
1622 update_mtime(os.path.join(scripts_path, "config"))
1623 add_unused_outputs(config, load_config)
1624 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1625 print("Config already loaded", file=sys.stderr)
1627 if "--debug" in options and load_config != dict(config):
1628 print("Loading profile '%s'" % load_profile)
1629 print_profile_differences(config, load_config)
1631 remove_irrelevant_outputs(config, load_config)
1634 if "--dry-run" in options:
1635 apply_configuration(load_config, config, True)
1638 "CURRENT_PROFILE": load_profile,
1639 "PROFILE_FOLDER": scripts_path,
1640 "MONITORS": ":".join(enabled_monitors(load_config)),
1642 exec_scripts(scripts_path, "preswitch", script_metadata)
1643 if "--debug" in options:
1644 print("Going to run:")
1645 apply_configuration(load_config, config, True)
1646 apply_configuration(load_config, config, False)
1647 exec_scripts(scripts_path, "postswitch", script_metadata)
1648 except AutorandrException as e:
1649 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1650 except Exception as e:
1651 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1653 if "--dry-run" not in options and "--debug" in options:
1654 new_config, _ = parse_xrandr_output(
1655 ignore_lid=ignore_lid,
1657 if "--skip-options" in options:
1658 for output in new_config.values():
1659 output.set_ignored_options(skip_options)
1660 if not is_equal_configuration(new_config, load_config):
1661 print("The configuration change did not go as expected:")
1662 print_profile_differences(new_config, load_config)
1667 def exception_handled_main(argv=sys.argv):
1670 except AutorandrException as e:
1671 print(e, file=sys.stderr)
1673 except Exception as e:
1674 if not len(str(e)): # BdbQuit
1675 print("Exception: {0}".format(e.__class__.__name__))
1678 print("Unhandled exception ({0}). Please report this as a bug at "
1679 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1684 if __name__ == '__main__':
1685 exception_handled_main()