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 from packaging.version import Version
49 except ModuleNotFoundError:
50 from distutils.version import LooseVersion as Version
52 if sys.version_info.major == 2:
53 import ConfigParser as configparser
57 __version__ = "1.12.1"
65 # (name, description, callback)
66 ("off", "Disable all outputs", None),
67 ("common", "Clone all connected outputs at the largest common resolution", None),
68 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
69 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
70 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
88 Usage: autorandr [options]
90 -h, --help get this small help
91 -c, --change automatically load the first detected profile
92 -d, --default <profile> make profile <profile> the default profile
93 -l, --load <profile> load profile <profile>
94 -s, --save <profile> save your current setup to profile <profile>
95 -r, --remove <profile> remove profile <profile>
96 --batch run autorandr for all users with active X11 sessions
97 --current only list current (active) configuration(s)
98 --config dump your current xrandr setup
99 --cycle automatically load the next detected profile
100 --debug enable verbose output
101 --detected only list detected (available) configuration(s)
102 --dry-run don't change anything, only print the xrandr commands
103 --fingerprint fingerprint your current hardware setup
104 --ignore-lid treat outputs as connected even if their lids are closed
105 --match-edid match diplays based on edid instead of name
106 --force force (re)loading of a profile / overwrite exiting files
107 --list list configurations
108 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
109 to skip both in detecting changes and applying a profile
110 --version show version information and exit
112 If no suitable profile can be identified, the current configuration is kept.
113 To change this behaviour and switch to a fallback configuration, specify
116 autorandr supports a set of per-profile and global hooks. See the documentation
119 The following virtual configurations are available:
123 def is_closed_lid(output):
124 if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
126 lids = glob.glob("/proc/acpi/button/lid/*/state")
129 with open(state_file) as f:
131 return "close" in content
135 class AutorandrException(Exception):
136 def __init__(self, message, original_exception=None, report_bug=False):
137 self.message = message
138 self.report_bug = report_bug
139 if original_exception:
140 self.original_exception = original_exception
141 trace = sys.exc_info()[2]
143 trace = trace.tb_next
144 self.line = trace.tb_lineno
145 self.file_name = trace.tb_frame.f_code.co_filename
149 frame = inspect.currentframe().f_back
150 self.line = frame.f_lineno
151 self.file_name = frame.f_code.co_filename
154 self.file_name = None
155 self.original_exception = None
157 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
158 self.file_name = None
161 retval = [self.message]
163 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
164 if self.original_exception:
165 retval.append(":\n ")
166 retval.append(str(self.original_exception).replace("\n", "\n "))
168 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
169 "\nhttps://github.com/phillipberndt/autorandr/issues"
170 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
171 return "".join(retval)
174 class XrandrOutput(object):
175 "Represents an XRandR output"
177 XRANDR_PROPERTIES_REGEXP = "|".join(
178 [r"{}:\s*(?P<{}>[\S ]*\S+)"
179 .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
180 for p in properties])
182 # This regular expression is used to parse an output in `xrandr --verbose'
183 XRANDR_OUTPUT_REGEXP = """(?x)
184 ^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
185 (?: # Differentiate disconnected and connected
186 disconnected | # in first line
187 unknown\ connection |
188 (?P<connected>connected)
191 (?P<primary>primary\ )? # Might be primary screen
193 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
194 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
195 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
196 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
197 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
198 )? # .. but only if the screen is in use.
199 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
200 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
201 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
202 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
203 (?:\s*(?: # Properties of the output
204 Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
205 CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
206 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
207 filter:\s+(?P<filter>bilinear|nearest) | # Transformation filter
208 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
209 """ + XRANDR_PROPERTIES_REGEXP + """ | # Properties to include in the profile
210 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
214 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
215 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
216 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
217 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
221 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
222 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
223 h:\s+width\s+(?P<width>[0-9]+).+\s+
224 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
227 XRANDR_13_DEFAULTS = {
228 "transform": "1,0,0,0,1,0,0,0,1",
232 XRANDR_12_DEFAULTS = {
235 "gamma": "1.0:1.0:1.0",
238 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
240 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
243 return "<%s%s %s>" % (self.output, self.fingerprint, " ".join(self.option_vector))
246 def short_edid(self):
247 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
250 def options_with_defaults(self):
251 "Return the options dictionary, augmented with the default values that weren't set"
252 if "off" in self.options:
255 if xrandr_version() >= Version("1.3"):
256 options.update(self.XRANDR_13_DEFAULTS)
257 if xrandr_version() >= Version("1.2"):
258 options.update(self.XRANDR_12_DEFAULTS)
259 options.update(self.options)
260 if "set" in self.ignored_options:
261 options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
262 return {a: b for a, b in options.items() if a not in self.ignored_options}
265 def filtered_options(self):
266 "Return a dictionary of options without ignored options"
267 options = {a: b for a, b in self.options.items() if a not in self.ignored_options}
268 if "set" in self.ignored_options:
269 options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
273 def option_vector(self):
274 "Return the command line parameters for XRandR for this instance"
275 args = ["--output", self.output]
276 for option, arg in sorted(self.options_with_defaults.items()):
277 if option.startswith("x-prop-"):
279 for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
280 if prop == option[7:]:
282 args.append(xrandr_prop)
286 print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
288 elif option.startswith("x-"):
289 print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
292 args.append("--%s" % option)
298 def option_string(self):
299 "Return the command line parameters in the configuration file format"
300 options = ["output %s" % self.output]
301 for option, arg in sorted(self.filtered_options.items()):
303 options.append("%s %s" % (option, arg))
305 options.append(option)
306 return "\n".join(options)
310 "Return a key to sort the outputs for xrandr invocation"
313 if "off" in self.options:
315 if "pos" in self.options:
316 x, y = map(float, self.options["pos"].split("x"))
321 def __init__(self, output, edid, options):
322 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
325 self.options = options
326 self.ignored_options = []
327 self.parse_serial_from_edid()
328 self.remove_default_option_values()
330 def parse_serial_from_edid(self):
333 if self.EDID_UNAVAILABLE in self.edid:
335 # Thx to pyedid project, the following code was
336 # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
337 raw = bytes.fromhex(self.edid)
338 # Check EDID header, and checksum
339 if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
341 serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
344 # Offsets of standard timing information descriptors 1-4
345 # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
346 for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
347 if timing_bytes[0:2] == b'\x00\x00':
348 timing_type = timing_bytes[3]
349 if timing_type == 0xFF:
350 buffer = timing_bytes[5:]
351 buffer = buffer.partition(b'\x0a')[0]
352 serial_text = buffer.decode('cp437')
353 self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
355 def set_ignored_options(self, options):
356 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
357 self.ignored_options = list(options)
359 def remove_default_option_values(self):
360 "Remove values from the options dictionary that are superflous"
361 if "off" in self.options and len(self.options.keys()) > 1:
362 self.options = {"off": None}
364 for option, default_value in self.XRANDR_DEFAULTS.items():
365 if option in self.options and self.options[option] == default_value:
366 del self.options[option]
369 def from_xrandr_output(cls, xrandr_output):
370 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
372 This method also returns a list of modes supported by the output.
375 xrandr_output = xrandr_output.replace("\r\n", "\n")
376 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
378 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
381 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
382 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
384 remainder = xrandr_output[len(match_object.group(0)):]
386 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
387 "regular expression, starting at byte %d with ..'%s'." %
388 (len(remainder), len(match_object.group(0)), remainder[:10]),
391 match = match_object.groupdict()
396 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
397 if mode_match.group("name"):
398 modes.append(mode_match.groupdict())
400 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
403 if not match["connected"]:
406 edid = "".join(match["edid"].strip().split())
408 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
410 # An output can be disconnected but still have a mode configured. This can only happen
411 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
414 # This code needs to be careful not to mix the two. An output should only be configured to
415 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
416 if not match["width"]:
417 options["off"] = None
419 if match["mode_name"]:
420 options["mode"] = match["mode_name"]
421 elif match["mode_width"]:
422 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
424 if match["rotate"] not in ("left", "right"):
425 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
427 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
429 options["rotate"] = match["rotate"]
431 options["primary"] = None
432 if match["reflect"] == "X":
433 options["reflect"] = "x"
434 elif match["reflect"] == "Y":
435 options["reflect"] = "y"
436 elif match["reflect"] == "X and Y":
437 options["reflect"] = "xy"
438 if match["x"] or match["y"]:
439 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
441 panning = [match["panning"]]
442 if match["tracking"]:
443 panning += ["/", match["tracking"]]
445 panning += ["/", match["border"]]
446 options["panning"] = "".join(panning)
447 if match["transform"]:
448 transformation = ",".join(match["transform"].strip().split())
449 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
450 options["transform"] = transformation
451 if not match["mode_name"]:
452 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
453 # I doubt that this special case is actually required.
454 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
455 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
457 options["filter"] = match["filter"]
459 gamma = match["gamma"].strip()
460 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
461 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
462 # so we approximate by 1e-10.
463 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
464 options["gamma"] = gamma
466 options["crtc"] = match["crtc"]
468 options["rate"] = match["rate"]
469 for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
471 options["x-prop-" + prop] = match[prop]
473 return XrandrOutput(match["output"], edid, options), modes
476 def from_config_file(cls, profile, edid_map, configuration):
477 "Instanciate an XrandrOutput from the contents of a configuration file"
479 for line in configuration.split("\n"):
481 line = line.split(None, 1)
482 if line and line[0].startswith("#"):
484 options[line[0]] = line[1] if len(line) > 1 else None
488 if options["output"] in edid_map:
489 edid = edid_map[options["output"]]
491 # This fuzzy matching is for legacy autorandr that used sysfs output names
492 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
493 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
494 if fuzzy_output in fuzzy_edid_map:
495 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
496 elif "off" not in options:
497 raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
498 "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
499 output = options["output"]
500 del options["output"]
502 return XrandrOutput(output, edid, options)
505 def fingerprint(self):
506 return str(self.serial) if self.serial else self.short_edid
508 def fingerprint_equals(self, other):
509 if self.serial and other.serial:
510 return self.serial == other.serial
512 return self.edid_equals(other)
514 def edid_equals(self, other):
515 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
516 if self.edid and other.edid:
517 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
518 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
519 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
520 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
522 return match_asterisk(self.edid, other.edid) > 0
523 elif "*" in other.edid:
524 return match_asterisk(other.edid, self.edid) > 0
525 return self.edid == other.edid
527 def __ne__(self, other):
528 return not (self == other)
530 def __eq__(self, other):
531 return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
533 def verbose_diff(self, other):
534 "Compare to another XrandrOutput and return a list of human readable differences"
536 if not self.fingerprint_equals(other):
537 diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
538 if self.output != other.output:
539 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
540 if "off" in self.options and "off" not in other.options:
541 diffs.append("The output is disabled currently, but active in the new configuration")
542 elif "off" in other.options and "off" not in self.options:
543 diffs.append("The output is currently enabled, but inactive in the new configuration")
545 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
546 if name not in other.options:
547 diffs.append("Option --%s %sis not present in the new configuration" %
548 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
549 elif name not in self.options:
550 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
551 (name, other.options[name]))
552 elif self.options[name] != other.options[name]:
553 diffs.append("Option --%s %sis `%s' in the new configuration" %
554 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
558 def xrandr_version():
559 "Return the version of XRandR that this system uses"
560 if getattr(xrandr_version, "version", False) is False:
561 version_string = os.popen("xrandr -v").read()
563 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
564 xrandr_version.version = Version(version)
565 except AttributeError:
566 xrandr_version.version = Version("1.3.0")
568 return xrandr_version.version
571 def debug_regexp(pattern, string):
572 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
575 bounds = (0, len(string))
576 while bounds[0] != bounds[1]:
577 half = int((bounds[0] + bounds[1]) / 2)
578 if half == bounds[0]:
580 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
581 partial_length = bounds[0]
582 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
583 (partial_length, string[max(0, partial_length - 20):partial_length],
584 string[partial_length:partial_length + 10]))
587 return "Debug information would be available if the `regex' module was installed."
590 def parse_xrandr_output(
594 "Parse the output of `xrandr --verbose' into a list of outputs"
595 xrandr_output = os.popen("xrandr -q --verbose").read()
596 if not xrandr_output:
597 raise AutorandrException("Failed to run xrandr")
599 # We are not interested in screens
600 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
602 # Split at output boundaries and instanciate an XrandrOutput per output
603 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
604 if len(split_xrandr_output) < 2:
605 raise AutorandrException("No output boundaries found", report_bug=True)
606 outputs = OrderedDict()
607 modes = OrderedDict()
608 for i in range(1, len(split_xrandr_output), 2):
609 output_name = split_xrandr_output[i].split()[0]
610 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
611 outputs[output_name] = output
613 modes[output_name] = output_modes
615 # consider a closed lid as disconnected if other outputs are connected
616 if not ignore_lid and sum(
621 for output_name in outputs.keys():
622 if is_closed_lid(output_name):
623 outputs[output_name].edid = None
625 return outputs, modes
628 def load_profiles(profile_path):
629 "Load the stored profiles"
632 for profile in os.listdir(profile_path):
633 config_name = os.path.join(profile_path, profile, "config")
634 setup_name = os.path.join(profile_path, profile, "setup")
635 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
638 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
642 for line in chain(open(config_name).readlines(), ["output"]):
643 if line[:6] == "output" and buffer:
644 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
649 for output_name in list(config.keys()):
650 if config[output_name].edid is None:
651 del config[output_name]
653 profiles[profile] = {
655 "path": os.path.join(profile_path, profile),
656 "config-mtime": os.stat(config_name).st_mtime,
662 def get_symlinks(profile_path):
663 "Load all symlinks from a directory"
666 for link in os.listdir(profile_path):
667 file_name = os.path.join(profile_path, link)
668 if os.path.islink(file_name):
669 symlinks[link] = os.readlink(file_name)
674 def match_asterisk(pattern, data):
675 """Match data against a pattern
677 The difference to fnmatch is that this function only accepts patterns with a single
678 asterisk and that it returns a "closeness" number, which is larger the better the match.
679 Zero indicates no match at all.
681 if "*" not in pattern:
682 return 1 if pattern == data else 0
683 parts = pattern.split("*")
685 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
686 if not data.startswith(parts[0]):
688 if not data.endswith(parts[1]):
690 matched = len(pattern)
691 total = len(data) + 1
692 return matched * 1. / total
695 def update_profiles_edid(profiles, config):
698 if config[c].fingerprint is not None:
699 fp_map[config[c].fingerprint] = c
702 profile_config = profiles[p]["config"]
704 for fingerprint in fp_map:
705 for c in list(profile_config.keys()):
706 if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
709 print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]))
711 tmp_disp = profile_config[c]
713 if fp_map[fingerprint] in profile_config:
714 # Swap the two entries
715 profile_config[c] = profile_config[fp_map[fingerprint]]
716 profile_config[c].output = c
718 # Object is reassigned to another key, drop this one
719 del profile_config[c]
721 profile_config[fp_map[fingerprint]] = tmp_disp
722 profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
725 def find_profiles(current_config, profiles):
726 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
727 detected_profiles = []
728 for profile_name, profile in profiles.items():
729 config = profile["config"]
731 for name, output in config.items():
732 if not output.fingerprint:
734 if name not in current_config or not output.fingerprint_equals(current_config[name]):
737 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
740 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
741 current_config[name].edid, output.edid))
742 detected_profiles.append((closeness, profile_name))
743 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
744 return detected_profiles
747 def profile_blocked(profile_path, meta_information=None):
748 """Check if a profile is blocked.
750 meta_information is expected to be an dictionary. It will be passed to the block scripts
751 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
753 return not exec_scripts(profile_path, "block", meta_information)
756 def check_configuration_pre_save(configuration):
757 "Check that a configuration is safe for saving."
758 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
759 for output in outputs:
760 if "off" not in configuration[output].options and not configuration[output].edid:
761 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
762 "This typically means that it has been recently unplugged and then not properly disabled\n"
763 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
764 "this command.") % {"o": output}
767 def output_configuration(configuration, config):
768 "Write a configuration file"
769 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
770 for output in outputs:
771 print(configuration[output].option_string, file=config)
774 def output_setup(configuration, setup):
775 "Write a setup (fingerprint) file"
776 outputs = sorted(configuration.keys())
777 for output in outputs:
778 if configuration[output].edid:
779 print(output, configuration[output].edid, file=setup)
782 def save_configuration(profile_path, profile_name, configuration, forced=False):
783 "Save a configuration into a profile"
784 if not os.path.isdir(profile_path):
785 os.makedirs(profile_path)
786 config_path = os.path.join(profile_path, "config")
787 setup_path = os.path.join(profile_path, "setup")
788 if os.path.isfile(config_path) and not forced:
789 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
790 if os.path.isfile(setup_path) and not forced:
791 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
793 with open(config_path, "w") as config:
794 output_configuration(configuration, config)
795 with open(setup_path, "w") as setup:
796 output_setup(configuration, setup)
799 def update_mtime(filename):
800 "Update a file's mtime"
802 os.utime(filename, None)
808 def call_and_retry(*args, **kwargs):
809 """Wrapper around subprocess.call that retries failed calls.
811 This function calls subprocess.call and on non-zero exit states,
812 waits a second and then retries once. This mitigates #47,
813 a timing issue with some drivers.
815 if kwargs.pop("dry_run", False):
817 print(shlex.quote(arg), end=" ")
821 if hasattr(subprocess, "DEVNULL"):
822 kwargs["stdout"] = getattr(subprocess, "DEVNULL")
824 kwargs["stdout"] = open(os.devnull, "w")
825 kwargs["stderr"] = kwargs["stdout"]
826 retval = subprocess.call(*args, **kwargs)
829 retval = subprocess.call(*args, **kwargs)
833 def get_fb_dimensions(configuration):
836 for output in configuration.values():
837 if "off" in output.options or not output.edid:
839 # This won't work with all modes -- but it's a best effort.
840 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
843 o_mode = match.group(0)
844 o_width, o_height = map(int, o_mode.split("x"))
845 if "transform" in output.options:
846 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
847 w = (g * o_width + h * o_height + i)
848 x = (a * o_width + b * o_height + c) / w
849 y = (d * o_width + e * o_height + f) / w
850 o_width, o_height = x, y
851 if "rotate" in output.options:
852 if output.options["rotate"] in ("left", "right"):
853 o_width, o_height = o_height, o_width
854 if "pos" in output.options:
855 o_left, o_top = map(int, output.options["pos"].split("x"))
858 if "panning" in output.options:
859 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
861 detail = match.groupdict(default="0")
862 o_width = int(detail.get("w")) + int(detail.get("x"))
863 o_height = int(detail.get("h")) + int(detail.get("y"))
864 width = max(width, o_width)
865 height = max(height, o_height)
866 return math.ceil(width), math.ceil(height)
869 def apply_configuration(new_configuration, current_configuration, dry_run=False):
870 "Apply a configuration"
871 found_top_left_monitor = False
872 found_left_monitor = False
873 found_top_monitor = False
874 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
875 base_argv = ["xrandr"]
877 # There are several xrandr / driver bugs we need to take care of here:
878 # - We cannot enable more than two screens at the same time
879 # See https://github.com/phillipberndt/autorandr/pull/6
880 # and commits f4cce4d and 8429886.
881 # - We cannot disable all screens
882 # See https://github.com/phillipberndt/autorandr/pull/20
883 # - We should disable screens before enabling others, because there's
884 # a limit on the number of enabled screens
885 # - We must make sure that the screen at 0x0 is activated first,
886 # or the other (first) screen to be activated would be moved there.
887 # - If an active screen already has a transformation and remains active,
888 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
889 # Update the configuration in 3 passes in that case. (On Haswell graphics,
891 # - Some implementations can not handle --transform at all, so avoid it unless
892 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
893 # - Some implementations can not handle --panning without specifying --fb
894 # explicitly, so avoid it unless necessary.
895 # (See https://github.com/phillipberndt/autorandr/issues/72)
897 fb_dimensions = get_fb_dimensions(new_configuration)
899 fb_args = ["--fb", "%dx%d" % fb_dimensions]
901 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
904 auxiliary_changes_pre = []
907 remain_active_count = 0
908 for output in outputs:
909 if not new_configuration[output].edid or "off" in new_configuration[output].options:
910 disable_outputs.append(new_configuration[output].option_vector)
912 if output not in current_configuration:
913 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
914 "Don't know how to proceed." % output)
915 if "off" not in current_configuration[output].options:
916 remain_active_count += 1
918 option_vector = new_configuration[output].option_vector
919 if xrandr_version() >= Version("1.3.0"):
920 for option, off_value in (("transform", "none"), ("panning", "0x0")):
921 if option in current_configuration[output].options:
922 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
925 option_index = option_vector.index("--%s" % option)
926 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
927 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
930 if not found_top_left_monitor:
931 position = new_configuration[output].options.get("pos", "0x0")
932 if position == "0x0":
933 found_top_left_monitor = True
934 enable_outputs.insert(0, option_vector)
935 elif not found_left_monitor and position.startswith("0x"):
936 found_left_monitor = True
937 enable_outputs.insert(0, option_vector)
938 elif not found_top_monitor and position.endswith("x0"):
939 found_top_monitor = True
940 enable_outputs.insert(0, option_vector)
942 enable_outputs.append(option_vector)
944 enable_outputs.append(option_vector)
946 # Perform pe-change auxiliary changes
947 if auxiliary_changes_pre:
948 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
949 if call_and_retry(argv, dry_run=dry_run) != 0:
950 raise AutorandrException("Command failed: %s" % " ".join(argv))
952 # Starting here, fix the frame buffer size
953 # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
954 # dimensions larger than they will finally be.
957 # Disable unused outputs, but make sure that there always is at least one active screen
958 disable_keep = 0 if remain_active_count else 1
959 if len(disable_outputs) > disable_keep:
960 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
961 if call_and_retry(argv, dry_run=dry_run) != 0:
962 # Disabling the outputs failed. Retry with the next command:
963 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
964 # This does not occur if simultaneously the primary screen is reset.
967 disable_outputs = disable_outputs[-1:] if disable_keep else []
969 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
970 # disable the last two screens. This is a problem, so if this would happen, instead disable only
971 # one screen in the first call below.
972 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
973 # In the context of a xrandr call that changes the display state, `--query' should do nothing
974 disable_outputs.insert(0, ['--query'])
976 # If we did not find a candidate, we might need to inject a call
977 # If there is no output to disable, we will enable 0x and x0 at the same time
978 if not found_top_left_monitor and len(disable_outputs) > 0:
979 # If the call to 0x and x0 is splitted, inject one of them
980 if found_top_monitor and found_left_monitor:
981 enable_outputs.insert(0, enable_outputs[0])
983 # Enable the remaining outputs in pairs of two operations
984 operations = disable_outputs + enable_outputs
985 for index in range(0, len(operations), 2):
986 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
987 if call_and_retry(argv, dry_run=dry_run) != 0:
988 raise AutorandrException("Command failed: %s" % " ".join(argv))
991 def is_equal_configuration(source_configuration, target_configuration):
993 Check if all outputs from target are already configured correctly in source and
994 that no other outputs are active.
996 for output in target_configuration.keys():
997 if "off" in target_configuration[output].options:
998 if (output in source_configuration and "off" not in source_configuration[output].options):
1001 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
1003 for output in source_configuration.keys():
1004 if "off" in source_configuration[output].options:
1005 if output in target_configuration and "off" not in target_configuration[output].options:
1008 if output not in target_configuration:
1013 def add_unused_outputs(source_configuration, target_configuration):
1014 "Add outputs that are missing in target to target, in 'off' state"
1015 for output_name, output in source_configuration.items():
1016 if output_name not in target_configuration:
1017 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1020 def remove_irrelevant_outputs(source_configuration, target_configuration):
1021 "Remove outputs from target that ought to be 'off' and already are"
1022 for output_name, output in source_configuration.items():
1023 if "off" in output.options:
1024 if output_name in target_configuration:
1025 if "off" in target_configuration[output_name].options:
1026 del target_configuration[output_name]
1029 def generate_virtual_profile(configuration, modes, profile_name):
1030 "Generate one of the virtual profiles"
1031 configuration = copy.deepcopy(configuration)
1032 if profile_name == "common":
1034 for output, output_modes in modes.items():
1036 if configuration[output].edid:
1037 for mode in output_modes:
1038 mode_set.add((mode["width"], mode["height"]))
1039 mode_sets.append(mode_set)
1040 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1041 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1042 if common_resolution:
1043 for output in configuration:
1044 configuration[output].options = {}
1045 if output in modes and configuration[output].edid:
1046 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1047 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1048 mode = modes_filtered[0]
1049 configuration[output].options["mode"] = mode['name']
1050 configuration[output].options["pos"] = "0x0"
1052 configuration[output].options["off"] = None
1053 elif profile_name in ("horizontal", "vertical"):
1055 if profile_name == "horizontal":
1056 shift_index = "width"
1057 pos_specifier = "%sx0"
1059 shift_index = "height"
1060 pos_specifier = "0x%s"
1062 for output in configuration:
1063 configuration[output].options = {}
1064 if output in modes and configuration[output].edid:
1066 score = int(a["width"]) * int(a["height"])
1070 output_modes = sorted(modes[output], key=key)
1071 mode = output_modes[-1]
1072 configuration[output].options["mode"] = mode["name"]
1073 configuration[output].options["rate"] = mode["rate"]
1074 configuration[output].options["pos"] = pos_specifier % shift
1075 shift += int(mode[shift_index])
1077 configuration[output].options["off"] = None
1078 elif profile_name == "clone-largest":
1079 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1080 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1081 biggest_resolution = modes_sorted[0]
1082 for output in configuration:
1083 configuration[output].options = {}
1084 if output in modes and configuration[output].edid:
1086 score = int(a["width"]) * int(a["height"])
1090 output_modes = sorted(modes[output], key=key)
1091 mode = output_modes[-1]
1092 configuration[output].options["mode"] = mode["name"]
1093 configuration[output].options["rate"] = mode["rate"]
1094 configuration[output].options["pos"] = "0x0"
1095 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1096 float(biggest_resolution["height"]) / float(mode["height"]))
1097 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1098 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1099 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1101 configuration[output].options["off"] = None
1102 elif profile_name == "off":
1103 for output in configuration:
1104 for key in list(configuration[output].options.keys()):
1105 del configuration[output].options[key]
1106 configuration[output].options["off"] = None
1107 return configuration
1110 def print_profile_differences(one, another):
1111 "Print the differences between two profiles for debugging"
1114 print("| Differences between the two profiles:")
1115 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1116 if output not in one:
1117 if "off" not in another[output].options:
1118 print("| Output `%s' is missing from the active configuration" % output)
1119 elif output not in another:
1120 if "off" not in one[output].options:
1121 print("| Output `%s' is missing from the new configuration" % output)
1123 for line in one[output].verbose_diff(another[output]):
1124 print("| [Output %s] %s" % (output, line))
1129 "Print help and exit"
1131 for profile in virtual_profiles:
1132 name, description = profile[:2]
1133 description = [description]
1135 while len(description[0]) > max_width + 1:
1136 left_over = description[0][max_width:]
1137 description[0] = description[0][:max_width] + "-"
1138 description.insert(1, " %-15s %s" % ("", left_over))
1139 description = "\n".join(description)
1140 print(" %-15s %s" % (name, description))
1144 def exec_scripts(profile_path, script_name, meta_information=None):
1147 This will run all executables from the profile folder, and global per-user
1148 and system-wide configuration folders, named script_name or residing in
1149 subdirectories named script_name.d.
1151 If profile_path is None, only global scripts will be invoked.
1153 meta_information is expected to be an dictionary. It will be passed to the block scripts
1154 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1156 Returns True unless any of the scripts exited with non-zero exit status.
1159 env = os.environ.copy()
1160 if meta_information:
1161 for key, value in meta_information.items():
1162 env["AUTORANDR_{}".format(key.upper())] = str(value)
1164 # If there are multiple candidates, the XDG spec tells to only use the first one.
1167 user_profile_path = os.path.expanduser("~/.autorandr")
1168 if not os.path.isdir(user_profile_path):
1169 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1171 candidate_directories = []
1173 candidate_directories.append(profile_path)
1174 candidate_directories.append(user_profile_path)
1175 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1176 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1178 for folder in candidate_directories:
1179 if script_name not in ran_scripts:
1180 script = os.path.join(folder, script_name)
1181 if os.access(script, os.X_OK | os.F_OK):
1183 all_ok &= subprocess.call(script, env=env) != 0
1185 raise AutorandrException("Failed to execute user command: %s" % (script,))
1186 ran_scripts.add(script_name)
1188 script_folder = os.path.join(folder, "%s.d" % script_name)
1189 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1190 for file_name in os.listdir(script_folder):
1191 check_name = "d/%s" % (file_name,)
1192 if check_name not in ran_scripts:
1193 script = os.path.join(script_folder, file_name)
1194 if os.access(script, os.X_OK | os.F_OK):
1196 all_ok &= subprocess.call(script, env=env) != 0
1198 raise AutorandrException("Failed to execute user command: %s" % (script,))
1199 ran_scripts.add(check_name)
1204 def dispatch_call_to_sessions(argv):
1205 """Invoke autorandr for each open local X11 session with the given options.
1207 The function iterates over all processes not owned by root and checks
1208 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1209 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1210 this display has been handled already. If it has not, it forks, changes
1211 uid/gid to the user owning the process, reuses the process's environment
1212 and runs autorandr with the parameters from argv.
1214 This function requires root permissions. It only works for X11 servers that
1215 have at least one non-root process running. It is susceptible for attacks
1216 where one user runs a process with another user's DISPLAY variable - in
1217 this case, it might happen that autorandr is invoked for the other user,
1218 which won't work. Since no other harm than prevention of automated
1219 execution of autorandr can be done this way, the assumption is that in this
1220 situation, the local administrator will handle the situation."""
1222 X11_displays_done = set()
1224 autorandr_binary = os.path.abspath(argv[0])
1225 backup_candidates = {}
1227 def fork_child_autorandr(pwent, process_environ):
1228 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1229 child_pid = os.fork()
1231 # This will throw an exception if any of the privilege changes fails,
1232 # so it should be safe. Also, note that since the environment
1233 # is taken from a process owned by the user, reusing it should
1234 # not leak any information.
1236 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1237 except AttributeError:
1238 # Python 2 doesn't have getgrouplist
1240 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1241 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1242 os.chdir(pwent.pw_dir)
1244 os.environ.update(process_environ)
1245 if sys.executable != "" and sys.executable != None:
1246 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1248 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1250 os.waitpid(child_pid, 0)
1252 # The following line assumes that user accounts start at 1000 and that no
1253 # one works using the root or another system account. This is rather
1254 # restrictive, but de facto default. If this breaks your use case, set the
1255 # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1256 # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1257 # effectively, both values aren't binding in any way.)
1259 if 'AUTORANDR_UID_MIN' in os.environ:
1260 uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1262 for directory in os.listdir("/proc"):
1263 directory = os.path.join("/proc/", directory)
1264 if not os.path.isdir(directory):
1266 environ_file = os.path.join(directory, "environ")
1267 if not os.path.isfile(environ_file):
1269 uid = os.stat(environ_file).st_uid
1274 process_environ = {}
1275 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1277 environ_entry = environ_entry.decode("ascii")
1278 except UnicodeDecodeError:
1280 name, sep, value = environ_entry.partition("=")
1282 if name == "DISPLAY" and "." in value:
1283 value = value[:value.find(".")]
1284 process_environ[name] = value
1286 if "DISPLAY" not in process_environ:
1287 # Cannot work with this environment, skip.
1290 # To allow scripts to detect batch invocation (especially useful for predetect)
1291 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1292 process_environ["UID"] = str(uid)
1294 display = process_environ["DISPLAY"]
1296 if "XAUTHORITY" not in process_environ:
1297 # It's very likely that we cannot work with this environment either,
1298 # but keep it as a backup just in case we don't find anything else.
1299 backup_candidates[display] = process_environ
1302 if display not in X11_displays_done:
1304 pwent = pwd.getpwuid(uid)
1306 # User has no pwd entry
1309 fork_child_autorandr(pwent, process_environ)
1310 X11_displays_done.add(display)
1312 # Run autorandr for any users/displays which didn't have a process with
1314 for display, process_environ in backup_candidates.items():
1315 if display not in X11_displays_done:
1317 pwent = pwd.getpwuid(int(process_environ["UID"]))
1319 # User has no pwd entry
1322 fork_child_autorandr(pwent, process_environ)
1323 X11_displays_done.add(display)
1326 def enabled_monitors(config):
1328 for monitor in config:
1329 if "--off" in config[monitor].option_vector:
1331 monitors.append(monitor)
1335 def read_config(options, directory):
1336 """Parse a configuration config.ini from directory and merge it into
1337 the options dictionary"""
1338 config = configparser.ConfigParser()
1339 config.read(os.path.join(directory, "settings.ini"))
1340 if config.has_section("config"):
1341 for key, value in config.items("config"):
1342 options.setdefault("--%s" % key, value)
1346 opts, args = getopt.getopt(
1372 except getopt.GetoptError as e:
1373 print("Failed to parse options: {0}.\n"
1374 "Use --help to get usage information.".format(str(e)),
1376 sys.exit(posix.EX_USAGE)
1378 options = dict(opts)
1380 if "-h" in options or "--help" in options:
1383 if "--version" in options:
1384 print("autorandr " + __version__)
1387 if "--current" in options and "--detected" in options:
1388 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1389 sys.exit(posix.EX_USAGE)
1392 if "--batch" in options:
1393 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1394 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1396 print("--batch mode can only be used by root and if $DISPLAY is unset")
1398 if "AUTORANDR_BATCH_PID" in os.environ:
1399 user = pwd.getpwuid(os.getuid())
1400 user = user.pw_name if user else "#%d" % os.getuid()
1401 print("autorandr running as user %s (started from batch instance)" % user)
1404 profile_symlinks = {}
1406 # Load profiles from each XDG config directory
1407 # The XDG spec says that earlier entries should take precedence, so reverse the order
1408 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1409 system_profile_path = os.path.join(directory, "autorandr")
1410 if os.path.isdir(system_profile_path):
1411 profiles.update(load_profiles(system_profile_path))
1412 profile_symlinks.update(get_symlinks(system_profile_path))
1413 read_config(options, system_profile_path)
1414 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1415 # profile_path is also used later on to store configurations
1416 profile_path = os.path.expanduser("~/.autorandr")
1417 if not os.path.isdir(profile_path):
1418 # Elsewise, follow the XDG specification
1419 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1420 if os.path.isdir(profile_path):
1421 profiles.update(load_profiles(profile_path))
1422 profile_symlinks.update(get_symlinks(profile_path))
1423 read_config(options, profile_path)
1424 except Exception as e:
1425 raise AutorandrException("Failed to load profiles", e)
1427 exec_scripts(None, "predetect")
1429 ignore_lid = "--ignore-lid" in options
1431 config, modes = parse_xrandr_output(
1432 ignore_lid=ignore_lid,
1435 if "--match-edid" in options:
1436 update_profiles_edid(profiles, config)
1440 if "--cycle" in options:
1441 # When cycling through profiles, put the profile least recently used to the top of the list
1443 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1444 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}
1446 if "--fingerprint" in options:
1447 output_setup(config, sys.stdout)
1450 if "--config" in options:
1451 output_configuration(config, sys.stdout)
1454 if "--skip-options" in options:
1455 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1456 for profile in profiles.values():
1457 for output in profile["config"].values():
1458 output.set_ignored_options(skip_options)
1459 for output in config.values():
1460 output.set_ignored_options(skip_options)
1463 options["--save"] = options["-s"]
1464 if "--save" in options:
1465 if options["--save"] in (x[0] for x in virtual_profiles):
1466 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1467 "This configuration name is a reserved virtual configuration." % options["--save"])
1468 error = check_configuration_pre_save(config)
1470 print("Cannot save current configuration as profile '%s':" % options["--save"])
1474 profile_folder = os.path.join(profile_path, options["--save"])
1475 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1476 exec_scripts(profile_folder, "postsave", {
1477 "CURRENT_PROFILE": options["--save"],
1478 "PROFILE_FOLDER": profile_folder,
1479 "MONITORS": ":".join(enabled_monitors(config)),
1481 except AutorandrException as e:
1483 except Exception as e:
1484 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1485 print("Saved current configuration as profile '%s'" % options["--save"])
1489 options["--remove"] = options["-r"]
1490 if "--remove" in options:
1491 if options["--remove"] in (x[0] for x in virtual_profiles):
1492 raise AutorandrException("Cannot remove profile '%s':\n"
1493 "This configuration name is a reserved virtual configuration." % options["--remove"])
1494 if options["--remove"] not in profiles.keys():
1495 raise AutorandrException("Cannot remove profile '%s':\n"
1496 "This profile does not exist." % options["--remove"])
1499 profile_folder = os.path.join(profile_path, options["--remove"])
1500 profile_dirlist = os.listdir(profile_folder)
1501 profile_dirlist.remove("config")
1502 profile_dirlist.remove("setup")
1504 print("Profile folder '%s' contains the following additional files:\n"
1505 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1506 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1507 if response != "yes":
1510 shutil.rmtree(profile_folder)
1511 print("Removed profile '%s'" % options["--remove"])
1513 print("Profile '%s' was not removed" % options["--remove"])
1514 except Exception as e:
1515 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1518 detected_profiles = find_profiles(config, profiles)
1519 load_profile = False
1522 options["--load"] = options["-l"]
1523 if "--load" in options:
1524 load_profile = options["--load"]
1525 elif len(args) == 1:
1526 load_profile = args[0]
1528 # Find the active profile(s) first, for the block script (See #42)
1529 current_profiles = []
1530 for profile_name in profiles.keys():
1531 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1532 if configs_are_equal:
1533 current_profiles.append(profile_name)
1534 block_script_metadata = {
1535 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1536 "CURRENT_PROFILES": ":".join(current_profiles)
1540 for profile_name in profiles.keys():
1541 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1542 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1543 print("%s (blocked)" % profile_name)
1546 is_current_profile = profile_name in current_profiles
1547 if profile_name in detected_profiles:
1548 if len(detected_profiles) == 1:
1550 props.append("(detected)")
1552 index = detected_profiles.index(profile_name) + 1
1553 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1554 if index < best_index:
1555 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1556 load_profile = profile_name
1558 elif "--detected" in options:
1560 if is_current_profile:
1561 props.append("(current)")
1562 elif "--current" in options:
1564 if any(opt in options for opt in ("--current", "--detected", "--list")):
1565 print("%s" % (profile_name, ))
1567 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1568 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1569 print_profile_differences(config, profiles[profile_name]["config"])
1572 options["--default"] = options["-d"]
1573 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1574 load_profile = options["--default"]
1577 if load_profile in profile_symlinks:
1578 if "--debug" in options:
1579 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1580 load_profile = profile_symlinks[load_profile]
1582 if load_profile in (x[0] for x in virtual_profiles):
1583 load_config = generate_virtual_profile(config, modes, load_profile)
1584 scripts_path = os.path.join(profile_path, load_profile)
1587 profile = profiles[load_profile]
1588 load_config = profile["config"]
1589 scripts_path = profile["path"]
1591 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1592 if "--dry-run" not in options:
1593 update_mtime(os.path.join(scripts_path, "config"))
1594 add_unused_outputs(config, load_config)
1595 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1596 print("Config already loaded", file=sys.stderr)
1598 if "--debug" in options and load_config != dict(config):
1599 print("Loading profile '%s'" % load_profile)
1600 print_profile_differences(config, load_config)
1602 remove_irrelevant_outputs(config, load_config)
1605 if "--dry-run" in options:
1606 apply_configuration(load_config, config, True)
1609 "CURRENT_PROFILE": load_profile,
1610 "PROFILE_FOLDER": scripts_path,
1611 "MONITORS": ":".join(enabled_monitors(load_config)),
1613 exec_scripts(scripts_path, "preswitch", script_metadata)
1614 if "--debug" in options:
1615 print("Going to run:")
1616 apply_configuration(load_config, config, True)
1617 apply_configuration(load_config, config, False)
1618 exec_scripts(scripts_path, "postswitch", script_metadata)
1619 except AutorandrException as e:
1620 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1621 except Exception as e:
1622 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1624 if "--dry-run" not in options and "--debug" in options:
1625 new_config, _ = parse_xrandr_output(
1626 ignore_lid=ignore_lid,
1628 if not is_equal_configuration(new_config, load_config):
1629 print("The configuration change did not go as expected:")
1630 print_profile_differences(new_config, load_config)
1635 def exception_handled_main(argv=sys.argv):
1638 except AutorandrException as e:
1639 print(e, file=sys.stderr)
1641 except Exception as e:
1642 if not len(str(e)): # BdbQuit
1643 print("Exception: {0}".format(e.__class__.__name__))
1646 print("Unhandled exception ({0}). Please report this as a bug at "
1647 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1652 if __name__ == '__main__':
1653 exception_handled_main()