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 return {a: b for a, b in options.items() if a not in self.ignored_options}
263 def filtered_options(self):
264 "Return a dictionary of options without ignored options"
265 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
268 def option_vector(self):
269 "Return the command line parameters for XRandR for this instance"
270 args = ["--output", self.output]
271 for option, arg in sorted(self.options_with_defaults.items()):
272 if option.startswith("x-prop-"):
274 for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
275 if prop == option[7:]:
277 args.append(xrandr_prop)
281 print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
283 elif option.startswith("x-"):
284 print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
287 args.append("--%s" % option)
293 def option_string(self):
294 "Return the command line parameters in the configuration file format"
295 options = ["output %s" % self.output]
296 for option, arg in sorted(self.filtered_options.items()):
298 options.append("%s %s" % (option, arg))
300 options.append(option)
301 return "\n".join(options)
305 "Return a key to sort the outputs for xrandr invocation"
308 if "off" in self.options:
310 if "pos" in self.options:
311 x, y = map(float, self.options["pos"].split("x"))
316 def __init__(self, output, edid, options):
317 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
320 self.options = options
321 self.ignored_options = []
322 self.parse_serial_from_edid()
323 self.remove_default_option_values()
325 def parse_serial_from_edid(self):
328 # Thx to pyedid project, the following code was
329 # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
330 raw = bytes.fromhex(self.edid)
331 # Check EDID header, and checksum
332 if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
334 serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
337 # Offsets of standard timing information descriptors 1-4
338 # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
339 for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
340 if timing_bytes[0:2] == b'\x00\x00':
341 timing_type = timing_bytes[3]
342 if timing_type == 0xFF:
343 buffer = timing_bytes[5:]
344 buffer = buffer.partition(b'\x0a')[0]
345 serial_text = buffer.decode('cp437')
346 self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
348 def set_ignored_options(self, options):
349 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
350 self.ignored_options = list(options)
352 def remove_default_option_values(self):
353 "Remove values from the options dictionary that are superflous"
354 if "off" in self.options and len(self.options.keys()) > 1:
355 self.options = {"off": None}
357 for option, default_value in self.XRANDR_DEFAULTS.items():
358 if option in self.options and self.options[option] == default_value:
359 del self.options[option]
362 def from_xrandr_output(cls, xrandr_output):
363 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
365 This method also returns a list of modes supported by the output.
368 xrandr_output = xrandr_output.replace("\r\n", "\n")
369 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
371 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
374 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
375 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
377 remainder = xrandr_output[len(match_object.group(0)):]
379 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
380 "regular expression, starting at byte %d with ..'%s'." %
381 (len(remainder), len(match_object.group(0)), remainder[:10]),
384 match = match_object.groupdict()
389 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
390 if mode_match.group("name"):
391 modes.append(mode_match.groupdict())
393 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
396 if not match["connected"]:
399 edid = "".join(match["edid"].strip().split())
401 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
403 # An output can be disconnected but still have a mode configured. This can only happen
404 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
407 # This code needs to be careful not to mix the two. An output should only be configured to
408 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
409 if not match["width"]:
410 options["off"] = None
412 if match["mode_name"]:
413 options["mode"] = match["mode_name"]
414 elif match["mode_width"]:
415 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
417 if match["rotate"] not in ("left", "right"):
418 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
420 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
422 options["rotate"] = match["rotate"]
424 options["primary"] = None
425 if match["reflect"] == "X":
426 options["reflect"] = "x"
427 elif match["reflect"] == "Y":
428 options["reflect"] = "y"
429 elif match["reflect"] == "X and Y":
430 options["reflect"] = "xy"
431 if match["x"] or match["y"]:
432 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
434 panning = [match["panning"]]
435 if match["tracking"]:
436 panning += ["/", match["tracking"]]
438 panning += ["/", match["border"]]
439 options["panning"] = "".join(panning)
440 if match["transform"]:
441 transformation = ",".join(match["transform"].strip().split())
442 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
443 options["transform"] = transformation
444 if not match["mode_name"]:
445 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
446 # I doubt that this special case is actually required.
447 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
448 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
450 options["filter"] = match["filter"]
452 gamma = match["gamma"].strip()
453 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
454 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
455 # so we approximate by 1e-10.
456 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
457 options["gamma"] = gamma
459 options["crtc"] = match["crtc"]
461 options["rate"] = match["rate"]
462 for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
464 options["x-prop-" + prop] = match[prop]
466 return XrandrOutput(match["output"], edid, options), modes
469 def from_config_file(cls, edid_map, configuration):
470 "Instanciate an XrandrOutput from the contents of a configuration file"
472 for line in configuration.split("\n"):
474 line = line.split(None, 1)
475 if line and line[0].startswith("#"):
477 options[line[0]] = line[1] if len(line) > 1 else None
481 if options["output"] in edid_map:
482 edid = edid_map[options["output"]]
484 # This fuzzy matching is for legacy autorandr that used sysfs output names
485 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
486 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
487 if fuzzy_output in fuzzy_edid_map:
488 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
489 elif "off" not in options:
490 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
491 "is not off in config file." % (options["output"], options["output"]))
492 output = options["output"]
493 del options["output"]
495 return XrandrOutput(output, edid, options)
498 def fingerprint(self):
499 return str(self.serial) if self.serial else self.short_edid
501 def fingerprint_equals(self, other):
502 if self.serial and other.serial:
503 return self.serial == other.serial
505 return self.edid_equals(other)
507 def edid_equals(self, other):
508 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
509 if self.edid and other.edid:
510 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
511 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
512 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
513 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
515 return match_asterisk(self.edid, other.edid) > 0
516 elif "*" in other.edid:
517 return match_asterisk(other.edid, self.edid) > 0
518 return self.edid == other.edid
520 def __ne__(self, other):
521 return not (self == other)
523 def __eq__(self, other):
524 return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
526 def verbose_diff(self, other):
527 "Compare to another XrandrOutput and return a list of human readable differences"
529 if not self.fingerprint_equals(other):
530 diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
531 if self.output != other.output:
532 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
533 if "off" in self.options and "off" not in other.options:
534 diffs.append("The output is disabled currently, but active in the new configuration")
535 elif "off" in other.options and "off" not in self.options:
536 diffs.append("The output is currently enabled, but inactive in the new configuration")
538 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
539 if name not in other.options:
540 diffs.append("Option --%s %sis not present in the new configuration" %
541 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
542 elif name not in self.options:
543 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
544 (name, other.options[name]))
545 elif self.options[name] != other.options[name]:
546 diffs.append("Option --%s %sis `%s' in the new configuration" %
547 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
551 def xrandr_version():
552 "Return the version of XRandR that this system uses"
553 if getattr(xrandr_version, "version", False) is False:
554 version_string = os.popen("xrandr -v").read()
556 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
557 xrandr_version.version = Version(version)
558 except AttributeError:
559 xrandr_version.version = Version("1.3.0")
561 return xrandr_version.version
564 def debug_regexp(pattern, string):
565 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
568 bounds = (0, len(string))
569 while bounds[0] != bounds[1]:
570 half = int((bounds[0] + bounds[1]) / 2)
571 if half == bounds[0]:
573 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
574 partial_length = bounds[0]
575 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
576 (partial_length, string[max(0, partial_length - 20):partial_length],
577 string[partial_length:partial_length + 10]))
580 return "Debug information would be available if the `regex' module was installed."
583 def parse_xrandr_output(
587 "Parse the output of `xrandr --verbose' into a list of outputs"
588 xrandr_output = os.popen("xrandr -q --verbose").read()
589 if not xrandr_output:
590 raise AutorandrException("Failed to run xrandr")
592 # We are not interested in screens
593 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
595 # Split at output boundaries and instanciate an XrandrOutput per output
596 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
597 if len(split_xrandr_output) < 2:
598 raise AutorandrException("No output boundaries found", report_bug=True)
599 outputs = OrderedDict()
600 modes = OrderedDict()
601 for i in range(1, len(split_xrandr_output), 2):
602 output_name = split_xrandr_output[i].split()[0]
603 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
604 outputs[output_name] = output
606 modes[output_name] = output_modes
608 # consider a closed lid as disconnected if other outputs are connected
609 if not ignore_lid and sum(
614 for output_name in outputs.keys():
615 if is_closed_lid(output_name):
616 outputs[output_name].edid = None
618 return outputs, modes
621 def load_profiles(profile_path):
622 "Load the stored profiles"
625 for profile in os.listdir(profile_path):
626 config_name = os.path.join(profile_path, profile, "config")
627 setup_name = os.path.join(profile_path, profile, "setup")
628 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
631 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
635 for line in chain(open(config_name).readlines(), ["output"]):
636 if line[:6] == "output" and buffer:
637 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
642 for output_name in list(config.keys()):
643 if config[output_name].edid is None:
644 del config[output_name]
646 profiles[profile] = {
648 "path": os.path.join(profile_path, profile),
649 "config-mtime": os.stat(config_name).st_mtime,
655 def get_symlinks(profile_path):
656 "Load all symlinks from a directory"
659 for link in os.listdir(profile_path):
660 file_name = os.path.join(profile_path, link)
661 if os.path.islink(file_name):
662 symlinks[link] = os.readlink(file_name)
667 def match_asterisk(pattern, data):
668 """Match data against a pattern
670 The difference to fnmatch is that this function only accepts patterns with a single
671 asterisk and that it returns a "closeness" number, which is larger the better the match.
672 Zero indicates no match at all.
674 if "*" not in pattern:
675 return 1 if pattern == data else 0
676 parts = pattern.split("*")
678 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
679 if not data.startswith(parts[0]):
681 if not data.endswith(parts[1]):
683 matched = len(pattern)
684 total = len(data) + 1
685 return matched * 1. / total
688 def update_profiles_edid(profiles, config):
691 if config[c].fingerprint is not None:
692 fp_map[config[c].fingerprint] = c
695 profile_config = profiles[p]["config"]
697 for fingerprint in fp_map:
698 for c in list(profile_config.keys()):
699 if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
702 print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]))
704 tmp_disp = profile_config[c]
706 if fp_map[fingerprint] in profile_config:
707 # Swap the two entries
708 profile_config[c] = profile_config[fp_map[fingerprint]]
709 profile_config[c].output = c
711 # Object is reassigned to another key, drop this one
712 del profile_config[c]
714 profile_config[fp_map[fingerprint]] = tmp_disp
715 profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
718 def find_profiles(current_config, profiles):
719 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
720 detected_profiles = []
721 for profile_name, profile in profiles.items():
722 config = profile["config"]
724 for name, output in config.items():
725 if not output.fingerprint:
727 if name not in current_config or not output.fingerprint_equals(current_config[name]):
730 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
733 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
734 current_config[name].edid, output.edid))
735 detected_profiles.append((closeness, profile_name))
736 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
737 return detected_profiles
740 def profile_blocked(profile_path, meta_information=None):
741 """Check if a profile is blocked.
743 meta_information is expected to be an dictionary. It will be passed to the block scripts
744 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
746 return not exec_scripts(profile_path, "block", meta_information)
749 def check_configuration_pre_save(configuration):
750 "Check that a configuration is safe for saving."
751 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
752 for output in outputs:
753 if "off" not in configuration[output].options and not configuration[output].edid:
754 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
755 "This typically means that it has been recently unplugged and then not properly disabled\n"
756 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
757 "this command.") % {"o": output}
760 def output_configuration(configuration, config):
761 "Write a configuration file"
762 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
763 for output in outputs:
764 print(configuration[output].option_string, file=config)
767 def output_setup(configuration, setup):
768 "Write a setup (fingerprint) file"
769 outputs = sorted(configuration.keys())
770 for output in outputs:
771 if configuration[output].edid:
772 print(output, configuration[output].edid, file=setup)
775 def save_configuration(profile_path, profile_name, configuration, forced=False):
776 "Save a configuration into a profile"
777 if not os.path.isdir(profile_path):
778 os.makedirs(profile_path)
779 config_path = os.path.join(profile_path, "config")
780 setup_path = os.path.join(profile_path, "setup")
781 if os.path.isfile(config_path) and not forced:
782 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
783 if os.path.isfile(setup_path) and not forced:
784 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
786 with open(config_path, "w") as config:
787 output_configuration(configuration, config)
788 with open(setup_path, "w") as setup:
789 output_setup(configuration, setup)
792 def update_mtime(filename):
793 "Update a file's mtime"
795 os.utime(filename, None)
801 def call_and_retry(*args, **kwargs):
802 """Wrapper around subprocess.call that retries failed calls.
804 This function calls subprocess.call and on non-zero exit states,
805 waits a second and then retries once. This mitigates #47,
806 a timing issue with some drivers.
808 if kwargs.pop("dry_run", False):
810 print(shlex.quote(arg), end=" ")
814 if hasattr(subprocess, "DEVNULL"):
815 kwargs["stdout"] = getattr(subprocess, "DEVNULL")
817 kwargs["stdout"] = open(os.devnull, "w")
818 kwargs["stderr"] = kwargs["stdout"]
819 retval = subprocess.call(*args, **kwargs)
822 retval = subprocess.call(*args, **kwargs)
826 def get_fb_dimensions(configuration):
829 for output in configuration.values():
830 if "off" in output.options or not output.edid:
832 # This won't work with all modes -- but it's a best effort.
833 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
836 o_mode = match.group(0)
837 o_width, o_height = map(int, o_mode.split("x"))
838 if "transform" in output.options:
839 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
840 w = (g * o_width + h * o_height + i)
841 x = (a * o_width + b * o_height + c) / w
842 y = (d * o_width + e * o_height + f) / w
843 o_width, o_height = x, y
844 if "rotate" in output.options:
845 if output.options["rotate"] in ("left", "right"):
846 o_width, o_height = o_height, o_width
847 if "pos" in output.options:
848 o_left, o_top = map(int, output.options["pos"].split("x"))
851 if "panning" in output.options:
852 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
854 detail = match.groupdict(default="0")
855 o_width = int(detail.get("w")) + int(detail.get("x"))
856 o_height = int(detail.get("h")) + int(detail.get("y"))
857 width = max(width, o_width)
858 height = max(height, o_height)
859 return math.ceil(width), math.ceil(height)
862 def apply_configuration(new_configuration, current_configuration, dry_run=False):
863 "Apply a configuration"
864 found_top_left_monitor = False
865 found_left_monitor = False
866 found_top_monitor = False
867 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
868 base_argv = ["xrandr"]
870 # There are several xrandr / driver bugs we need to take care of here:
871 # - We cannot enable more than two screens at the same time
872 # See https://github.com/phillipberndt/autorandr/pull/6
873 # and commits f4cce4d and 8429886.
874 # - We cannot disable all screens
875 # See https://github.com/phillipberndt/autorandr/pull/20
876 # - We should disable screens before enabling others, because there's
877 # a limit on the number of enabled screens
878 # - We must make sure that the screen at 0x0 is activated first,
879 # or the other (first) screen to be activated would be moved there.
880 # - If an active screen already has a transformation and remains active,
881 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
882 # Update the configuration in 3 passes in that case. (On Haswell graphics,
884 # - Some implementations can not handle --transform at all, so avoid it unless
885 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
886 # - Some implementations can not handle --panning without specifying --fb
887 # explicitly, so avoid it unless necessary.
888 # (See https://github.com/phillipberndt/autorandr/issues/72)
890 fb_dimensions = get_fb_dimensions(new_configuration)
892 base_argv += ["--fb", "%dx%d" % fb_dimensions]
894 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
897 auxiliary_changes_pre = []
900 remain_active_count = 0
901 for output in outputs:
902 if not new_configuration[output].edid or "off" in new_configuration[output].options:
903 disable_outputs.append(new_configuration[output].option_vector)
905 if output not in current_configuration:
906 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
907 "Don't know how to proceed." % output)
908 if "off" not in current_configuration[output].options:
909 remain_active_count += 1
911 option_vector = new_configuration[output].option_vector
912 if xrandr_version() >= Version("1.3.0"):
913 for option, off_value in (("transform", "none"), ("panning", "0x0")):
914 if option in current_configuration[output].options:
915 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
918 option_index = option_vector.index("--%s" % option)
919 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
920 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
923 if not found_top_left_monitor:
924 position = new_configuration[output].options.get("pos", "0x0")
925 if position == "0x0":
926 found_top_left_monitor = True
927 enable_outputs.insert(0, option_vector)
928 elif not found_left_monitor and position.startswith("0x"):
929 found_left_monitor = True
930 enable_outputs.insert(0, option_vector)
931 elif not found_top_monitor and position.endswith("x0"):
932 found_top_monitor = True
933 enable_outputs.insert(0, option_vector)
935 enable_outputs.append(option_vector)
937 enable_outputs.append(option_vector)
939 # Perform pe-change auxiliary changes
940 if auxiliary_changes_pre:
941 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
942 if call_and_retry(argv, dry_run=dry_run) != 0:
943 raise AutorandrException("Command failed: %s" % " ".join(argv))
945 # Disable unused outputs, but make sure that there always is at least one active screen
946 disable_keep = 0 if remain_active_count else 1
947 if len(disable_outputs) > disable_keep:
948 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
949 if call_and_retry(argv, dry_run=dry_run) != 0:
950 # Disabling the outputs failed. Retry with the next command:
951 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
952 # This does not occur if simultaneously the primary screen is reset.
955 disable_outputs = disable_outputs[-1:] if disable_keep else []
957 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
958 # disable the last two screens. This is a problem, so if this would happen, instead disable only
959 # one screen in the first call below.
960 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
961 # In the context of a xrandr call that changes the display state, `--query' should do nothing
962 disable_outputs.insert(0, ['--query'])
964 # If we did not find a candidate, we might need to inject a call
965 # If there is no output to disable, we will enable 0x and x0 at the same time
966 if not found_top_left_monitor and len(disable_outputs) > 0:
967 # If the call to 0x and x0 is splitted, inject one of them
968 if found_top_monitor and found_left_monitor:
969 enable_outputs.insert(0, enable_outputs[0])
971 # Enable the remaining outputs in pairs of two operations
972 operations = disable_outputs + enable_outputs
973 for index in range(0, len(operations), 2):
974 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
975 if call_and_retry(argv, dry_run=dry_run) != 0:
976 raise AutorandrException("Command failed: %s" % " ".join(argv))
979 def is_equal_configuration(source_configuration, target_configuration):
981 Check if all outputs from target are already configured correctly in source and
982 that no other outputs are active.
984 for output in target_configuration.keys():
985 if "off" in target_configuration[output].options:
986 if (output in source_configuration and "off" not in source_configuration[output].options):
989 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
991 for output in source_configuration.keys():
992 if "off" in source_configuration[output].options:
993 if output in target_configuration and "off" not in target_configuration[output].options:
996 if output not in target_configuration:
1001 def add_unused_outputs(source_configuration, target_configuration):
1002 "Add outputs that are missing in target to target, in 'off' state"
1003 for output_name, output in source_configuration.items():
1004 if output_name not in target_configuration:
1005 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1008 def remove_irrelevant_outputs(source_configuration, target_configuration):
1009 "Remove outputs from target that ought to be 'off' and already are"
1010 for output_name, output in source_configuration.items():
1011 if "off" in output.options:
1012 if output_name in target_configuration:
1013 if "off" in target_configuration[output_name].options:
1014 del target_configuration[output_name]
1017 def generate_virtual_profile(configuration, modes, profile_name):
1018 "Generate one of the virtual profiles"
1019 configuration = copy.deepcopy(configuration)
1020 if profile_name == "common":
1022 for output, output_modes in modes.items():
1024 if configuration[output].edid:
1025 for mode in output_modes:
1026 mode_set.add((mode["width"], mode["height"]))
1027 mode_sets.append(mode_set)
1028 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1029 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1030 if common_resolution:
1031 for output in configuration:
1032 configuration[output].options = {}
1033 if output in modes and configuration[output].edid:
1034 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1035 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1036 mode = modes_filtered[0]
1037 configuration[output].options["mode"] = mode['name']
1038 configuration[output].options["pos"] = "0x0"
1040 configuration[output].options["off"] = None
1041 elif profile_name in ("horizontal", "vertical"):
1043 if profile_name == "horizontal":
1044 shift_index = "width"
1045 pos_specifier = "%sx0"
1047 shift_index = "height"
1048 pos_specifier = "0x%s"
1050 for output in configuration:
1051 configuration[output].options = {}
1052 if output in modes and configuration[output].edid:
1054 score = int(a["width"]) * int(a["height"])
1058 output_modes = sorted(modes[output], key=key)
1059 mode = output_modes[-1]
1060 configuration[output].options["mode"] = mode["name"]
1061 configuration[output].options["rate"] = mode["rate"]
1062 configuration[output].options["pos"] = pos_specifier % shift
1063 shift += int(mode[shift_index])
1065 configuration[output].options["off"] = None
1066 elif profile_name == "clone-largest":
1067 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1068 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1069 biggest_resolution = modes_sorted[0]
1070 for output in configuration:
1071 configuration[output].options = {}
1072 if output in modes and configuration[output].edid:
1074 score = int(a["width"]) * int(a["height"])
1078 output_modes = sorted(modes[output], key=key)
1079 mode = output_modes[-1]
1080 configuration[output].options["mode"] = mode["name"]
1081 configuration[output].options["rate"] = mode["rate"]
1082 configuration[output].options["pos"] = "0x0"
1083 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1084 float(biggest_resolution["height"]) / float(mode["height"]))
1085 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1086 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1087 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1089 configuration[output].options["off"] = None
1090 elif profile_name == "off":
1091 for output in configuration:
1092 for key in list(configuration[output].options.keys()):
1093 del configuration[output].options[key]
1094 configuration[output].options["off"] = None
1095 return configuration
1098 def print_profile_differences(one, another):
1099 "Print the differences between two profiles for debugging"
1102 print("| Differences between the two profiles:")
1103 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1104 if output not in one:
1105 if "off" not in another[output].options:
1106 print("| Output `%s' is missing from the active configuration" % output)
1107 elif output not in another:
1108 if "off" not in one[output].options:
1109 print("| Output `%s' is missing from the new configuration" % output)
1111 for line in one[output].verbose_diff(another[output]):
1112 print("| [Output %s] %s" % (output, line))
1117 "Print help and exit"
1119 for profile in virtual_profiles:
1120 name, description = profile[:2]
1121 description = [description]
1123 while len(description[0]) > max_width + 1:
1124 left_over = description[0][max_width:]
1125 description[0] = description[0][:max_width] + "-"
1126 description.insert(1, " %-15s %s" % ("", left_over))
1127 description = "\n".join(description)
1128 print(" %-15s %s" % (name, description))
1132 def exec_scripts(profile_path, script_name, meta_information=None):
1135 This will run all executables from the profile folder, and global per-user
1136 and system-wide configuration folders, named script_name or residing in
1137 subdirectories named script_name.d.
1139 If profile_path is None, only global scripts will be invoked.
1141 meta_information is expected to be an dictionary. It will be passed to the block scripts
1142 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1144 Returns True unless any of the scripts exited with non-zero exit status.
1147 env = os.environ.copy()
1148 if meta_information:
1149 for key, value in meta_information.items():
1150 env["AUTORANDR_{}".format(key.upper())] = str(value)
1152 # If there are multiple candidates, the XDG spec tells to only use the first one.
1155 user_profile_path = os.path.expanduser("~/.autorandr")
1156 if not os.path.isdir(user_profile_path):
1157 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1159 candidate_directories = []
1161 candidate_directories.append(profile_path)
1162 candidate_directories.append(user_profile_path)
1163 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1164 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1166 for folder in candidate_directories:
1167 if script_name not in ran_scripts:
1168 script = os.path.join(folder, script_name)
1169 if os.access(script, os.X_OK | os.F_OK):
1171 all_ok &= subprocess.call(script, env=env) != 0
1173 raise AutorandrException("Failed to execute user command: %s" % (script,))
1174 ran_scripts.add(script_name)
1176 script_folder = os.path.join(folder, "%s.d" % script_name)
1177 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1178 for file_name in os.listdir(script_folder):
1179 check_name = "d/%s" % (file_name,)
1180 if check_name not in ran_scripts:
1181 script = os.path.join(script_folder, file_name)
1182 if os.access(script, os.X_OK | os.F_OK):
1184 all_ok &= subprocess.call(script, env=env) != 0
1186 raise AutorandrException("Failed to execute user command: %s" % (script,))
1187 ran_scripts.add(check_name)
1192 def dispatch_call_to_sessions(argv):
1193 """Invoke autorandr for each open local X11 session with the given options.
1195 The function iterates over all processes not owned by root and checks
1196 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1197 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1198 this display has been handled already. If it has not, it forks, changes
1199 uid/gid to the user owning the process, reuses the process's environment
1200 and runs autorandr with the parameters from argv.
1202 This function requires root permissions. It only works for X11 servers that
1203 have at least one non-root process running. It is susceptible for attacks
1204 where one user runs a process with another user's DISPLAY variable - in
1205 this case, it might happen that autorandr is invoked for the other user,
1206 which won't work. Since no other harm than prevention of automated
1207 execution of autorandr can be done this way, the assumption is that in this
1208 situation, the local administrator will handle the situation."""
1210 X11_displays_done = set()
1212 autorandr_binary = os.path.abspath(argv[0])
1213 backup_candidates = {}
1215 def fork_child_autorandr(pwent, process_environ):
1216 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1217 child_pid = os.fork()
1219 # This will throw an exception if any of the privilege changes fails,
1220 # so it should be safe. Also, note that since the environment
1221 # is taken from a process owned by the user, reusing it should
1222 # not leak any information.
1224 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1225 except AttributeError:
1226 # Python 2 doesn't have getgrouplist
1228 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1229 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1230 os.chdir(pwent.pw_dir)
1232 os.environ.update(process_environ)
1233 if sys.executable != "" and sys.executable != None:
1234 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1236 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1238 os.waitpid(child_pid, 0)
1240 # The following line assumes that user accounts start at 1000 and that no
1241 # one works using the root or another system account. This is rather
1242 # restrictive, but de facto default. If this breaks your use case, set the
1243 # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1244 # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1245 # effectively, both values aren't binding in any way.)
1247 if 'AUTORANDR_UID_MIN' in os.environ:
1248 uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1250 for directory in os.listdir("/proc"):
1251 directory = os.path.join("/proc/", directory)
1252 if not os.path.isdir(directory):
1254 environ_file = os.path.join(directory, "environ")
1255 if not os.path.isfile(environ_file):
1257 uid = os.stat(environ_file).st_uid
1262 process_environ = {}
1263 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1265 environ_entry = environ_entry.decode("ascii")
1266 except UnicodeDecodeError:
1268 name, sep, value = environ_entry.partition("=")
1270 if name == "DISPLAY" and "." in value:
1271 value = value[:value.find(".")]
1272 process_environ[name] = value
1274 if "DISPLAY" not in process_environ:
1275 # Cannot work with this environment, skip.
1278 # To allow scripts to detect batch invocation (especially useful for predetect)
1279 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1280 process_environ["UID"] = str(uid)
1282 display = process_environ["DISPLAY"]
1284 if "XAUTHORITY" not in process_environ:
1285 # It's very likely that we cannot work with this environment either,
1286 # but keep it as a backup just in case we don't find anything else.
1287 backup_candidates[display] = process_environ
1290 if display not in X11_displays_done:
1292 pwent = pwd.getpwuid(uid)
1294 # User has no pwd entry
1297 fork_child_autorandr(pwent, process_environ)
1298 X11_displays_done.add(display)
1300 # Run autorandr for any users/displays which didn't have a process with
1302 for display, process_environ in backup_candidates.items():
1303 if display not in X11_displays_done:
1305 pwent = pwd.getpwuid(int(process_environ["UID"]))
1307 # User has no pwd entry
1310 fork_child_autorandr(pwent, process_environ)
1311 X11_displays_done.add(display)
1314 def enabled_monitors(config):
1316 for monitor in config:
1317 if "--off" in config[monitor].option_vector:
1319 monitors.append(monitor)
1323 def read_config(options, directory):
1324 """Parse a configuration config.ini from directory and merge it into
1325 the options dictionary"""
1326 config = configparser.ConfigParser()
1327 config.read(os.path.join(directory, "settings.ini"))
1328 if config.has_section("config"):
1329 for key, value in config.items("config"):
1330 options.setdefault("--%s" % key, value)
1334 opts, args = getopt.getopt(
1360 except getopt.GetoptError as e:
1361 print("Failed to parse options: {0}.\n"
1362 "Use --help to get usage information.".format(str(e)),
1364 sys.exit(posix.EX_USAGE)
1366 options = dict(opts)
1368 if "-h" in options or "--help" in options:
1371 if "--version" in options:
1372 print("autorandr " + __version__)
1375 if "--current" in options and "--detected" in options:
1376 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1377 sys.exit(posix.EX_USAGE)
1380 if "--batch" in options:
1381 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1382 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1384 print("--batch mode can only be used by root and if $DISPLAY is unset")
1386 if "AUTORANDR_BATCH_PID" in os.environ:
1387 user = pwd.getpwuid(os.getuid())
1388 user = user.pw_name if user else "#%d" % os.getuid()
1389 print("autorandr running as user %s (started from batch instance)" % user)
1392 profile_symlinks = {}
1394 # Load profiles from each XDG config directory
1395 # The XDG spec says that earlier entries should take precedence, so reverse the order
1396 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1397 system_profile_path = os.path.join(directory, "autorandr")
1398 if os.path.isdir(system_profile_path):
1399 profiles.update(load_profiles(system_profile_path))
1400 profile_symlinks.update(get_symlinks(system_profile_path))
1401 read_config(options, system_profile_path)
1402 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1403 # profile_path is also used later on to store configurations
1404 profile_path = os.path.expanduser("~/.autorandr")
1405 if not os.path.isdir(profile_path):
1406 # Elsewise, follow the XDG specification
1407 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1408 if os.path.isdir(profile_path):
1409 profiles.update(load_profiles(profile_path))
1410 profile_symlinks.update(get_symlinks(profile_path))
1411 read_config(options, profile_path)
1412 except Exception as e:
1413 raise AutorandrException("Failed to load profiles", e)
1415 exec_scripts(None, "predetect")
1417 ignore_lid = "--ignore-lid" in options
1419 config, modes = parse_xrandr_output(
1420 ignore_lid=ignore_lid,
1423 if "--match-edid" in options:
1424 update_profiles_edid(profiles, config)
1428 if "--cycle" in options:
1429 # When cycling through profiles, put the profile least recently used to the top of the list
1431 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1432 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}
1434 if "--fingerprint" in options:
1435 output_setup(config, sys.stdout)
1438 if "--config" in options:
1439 output_configuration(config, sys.stdout)
1442 if "--skip-options" in options:
1443 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1444 for profile in profiles.values():
1445 for output in profile["config"].values():
1446 output.set_ignored_options(skip_options)
1447 for output in config.values():
1448 output.set_ignored_options(skip_options)
1451 options["--save"] = options["-s"]
1452 if "--save" in options:
1453 if options["--save"] in (x[0] for x in virtual_profiles):
1454 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1455 "This configuration name is a reserved virtual configuration." % options["--save"])
1456 error = check_configuration_pre_save(config)
1458 print("Cannot save current configuration as profile '%s':" % options["--save"])
1462 profile_folder = os.path.join(profile_path, options["--save"])
1463 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1464 exec_scripts(profile_folder, "postsave", {
1465 "CURRENT_PROFILE": options["--save"],
1466 "PROFILE_FOLDER": profile_folder,
1467 "MONITORS": ":".join(enabled_monitors(config)),
1469 except AutorandrException as e:
1471 except Exception as e:
1472 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1473 print("Saved current configuration as profile '%s'" % options["--save"])
1477 options["--remove"] = options["-r"]
1478 if "--remove" in options:
1479 if options["--remove"] in (x[0] for x in virtual_profiles):
1480 raise AutorandrException("Cannot remove profile '%s':\n"
1481 "This configuration name is a reserved virtual configuration." % options["--remove"])
1482 if options["--remove"] not in profiles.keys():
1483 raise AutorandrException("Cannot remove profile '%s':\n"
1484 "This profile does not exist." % options["--remove"])
1487 profile_folder = os.path.join(profile_path, options["--remove"])
1488 profile_dirlist = os.listdir(profile_folder)
1489 profile_dirlist.remove("config")
1490 profile_dirlist.remove("setup")
1492 print("Profile folder '%s' contains the following additional files:\n"
1493 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1494 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1495 if response != "yes":
1498 shutil.rmtree(profile_folder)
1499 print("Removed profile '%s'" % options["--remove"])
1501 print("Profile '%s' was not removed" % options["--remove"])
1502 except Exception as e:
1503 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1506 detected_profiles = find_profiles(config, profiles)
1507 load_profile = False
1510 options["--load"] = options["-l"]
1511 if "--load" in options:
1512 load_profile = options["--load"]
1513 elif len(args) == 1:
1514 load_profile = args[0]
1516 # Find the active profile(s) first, for the block script (See #42)
1517 current_profiles = []
1518 for profile_name in profiles.keys():
1519 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1520 if configs_are_equal:
1521 current_profiles.append(profile_name)
1522 block_script_metadata = {
1523 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1524 "CURRENT_PROFILES": ":".join(current_profiles)
1528 for profile_name in profiles.keys():
1529 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1530 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1531 print("%s (blocked)" % profile_name)
1534 is_current_profile = profile_name in current_profiles
1535 if profile_name in detected_profiles:
1536 if len(detected_profiles) == 1:
1538 props.append("(detected)")
1540 index = detected_profiles.index(profile_name) + 1
1541 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1542 if index < best_index:
1543 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1544 load_profile = profile_name
1546 elif "--detected" in options:
1548 if is_current_profile:
1549 props.append("(current)")
1550 elif "--current" in options:
1552 if any(opt in options for opt in ("--current", "--detected", "--list")):
1553 print("%s" % (profile_name, ))
1555 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1556 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1557 print_profile_differences(config, profiles[profile_name]["config"])
1560 options["--default"] = options["-d"]
1561 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1562 load_profile = options["--default"]
1565 if load_profile in profile_symlinks:
1566 if "--debug" in options:
1567 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1568 load_profile = profile_symlinks[load_profile]
1570 if load_profile in (x[0] for x in virtual_profiles):
1571 load_config = generate_virtual_profile(config, modes, load_profile)
1572 scripts_path = os.path.join(profile_path, load_profile)
1575 profile = profiles[load_profile]
1576 load_config = profile["config"]
1577 scripts_path = profile["path"]
1579 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1580 if "--dry-run" not in options:
1581 update_mtime(os.path.join(scripts_path, "config"))
1582 add_unused_outputs(config, load_config)
1583 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1584 print("Config already loaded", file=sys.stderr)
1586 if "--debug" in options and load_config != dict(config):
1587 print("Loading profile '%s'" % load_profile)
1588 print_profile_differences(config, load_config)
1590 remove_irrelevant_outputs(config, load_config)
1593 if "--dry-run" in options:
1594 apply_configuration(load_config, config, True)
1597 "CURRENT_PROFILE": load_profile,
1598 "PROFILE_FOLDER": scripts_path,
1599 "MONITORS": ":".join(enabled_monitors(load_config)),
1601 exec_scripts(scripts_path, "preswitch", script_metadata)
1602 if "--debug" in options:
1603 print("Going to run:")
1604 apply_configuration(load_config, config, True)
1605 apply_configuration(load_config, config, False)
1606 exec_scripts(scripts_path, "postswitch", script_metadata)
1607 except AutorandrException as e:
1608 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1609 except Exception as e:
1610 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1612 if "--dry-run" not in options and "--debug" in options:
1613 new_config, _ = parse_xrandr_output(
1614 ignore_lid=ignore_lid,
1616 if not is_equal_configuration(new_config, load_config):
1617 print("The configuration change did not go as expected:")
1618 print_profile_differences(new_config, load_config)
1623 def exception_handled_main(argv=sys.argv):
1626 except AutorandrException as e:
1627 print(e, file=sys.stderr)
1629 except Exception as e:
1630 if not len(str(e)): # BdbQuit
1631 print("Exception: {0}".format(e.__class__.__name__))
1634 print("Unhandled exception ({0}). Please report this as a bug at "
1635 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1640 if __name__ == '__main__':
1641 exception_handled_main()