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 fb_args = ["--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 # Starting here, fix the frame buffer size
946 # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
947 # dimensions larger than they will finally be.
950 # Disable unused outputs, but make sure that there always is at least one active screen
951 disable_keep = 0 if remain_active_count else 1
952 if len(disable_outputs) > disable_keep:
953 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
954 if call_and_retry(argv, dry_run=dry_run) != 0:
955 # Disabling the outputs failed. Retry with the next command:
956 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
957 # This does not occur if simultaneously the primary screen is reset.
960 disable_outputs = disable_outputs[-1:] if disable_keep else []
962 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
963 # disable the last two screens. This is a problem, so if this would happen, instead disable only
964 # one screen in the first call below.
965 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
966 # In the context of a xrandr call that changes the display state, `--query' should do nothing
967 disable_outputs.insert(0, ['--query'])
969 # If we did not find a candidate, we might need to inject a call
970 # If there is no output to disable, we will enable 0x and x0 at the same time
971 if not found_top_left_monitor and len(disable_outputs) > 0:
972 # If the call to 0x and x0 is splitted, inject one of them
973 if found_top_monitor and found_left_monitor:
974 enable_outputs.insert(0, enable_outputs[0])
976 # Enable the remaining outputs in pairs of two operations
977 operations = disable_outputs + enable_outputs
978 for index in range(0, len(operations), 2):
979 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
980 if call_and_retry(argv, dry_run=dry_run) != 0:
981 raise AutorandrException("Command failed: %s" % " ".join(argv))
984 def is_equal_configuration(source_configuration, target_configuration):
986 Check if all outputs from target are already configured correctly in source and
987 that no other outputs are active.
989 for output in target_configuration.keys():
990 if "off" in target_configuration[output].options:
991 if (output in source_configuration and "off" not in source_configuration[output].options):
994 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
996 for output in source_configuration.keys():
997 if "off" in source_configuration[output].options:
998 if output in target_configuration and "off" not in target_configuration[output].options:
1001 if output not in target_configuration:
1006 def add_unused_outputs(source_configuration, target_configuration):
1007 "Add outputs that are missing in target to target, in 'off' state"
1008 for output_name, output in source_configuration.items():
1009 if output_name not in target_configuration:
1010 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1013 def remove_irrelevant_outputs(source_configuration, target_configuration):
1014 "Remove outputs from target that ought to be 'off' and already are"
1015 for output_name, output in source_configuration.items():
1016 if "off" in output.options:
1017 if output_name in target_configuration:
1018 if "off" in target_configuration[output_name].options:
1019 del target_configuration[output_name]
1022 def generate_virtual_profile(configuration, modes, profile_name):
1023 "Generate one of the virtual profiles"
1024 configuration = copy.deepcopy(configuration)
1025 if profile_name == "common":
1027 for output, output_modes in modes.items():
1029 if configuration[output].edid:
1030 for mode in output_modes:
1031 mode_set.add((mode["width"], mode["height"]))
1032 mode_sets.append(mode_set)
1033 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1034 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1035 if common_resolution:
1036 for output in configuration:
1037 configuration[output].options = {}
1038 if output in modes and configuration[output].edid:
1039 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1040 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1041 mode = modes_filtered[0]
1042 configuration[output].options["mode"] = mode['name']
1043 configuration[output].options["pos"] = "0x0"
1045 configuration[output].options["off"] = None
1046 elif profile_name in ("horizontal", "vertical"):
1048 if profile_name == "horizontal":
1049 shift_index = "width"
1050 pos_specifier = "%sx0"
1052 shift_index = "height"
1053 pos_specifier = "0x%s"
1055 for output in configuration:
1056 configuration[output].options = {}
1057 if output in modes and configuration[output].edid:
1059 score = int(a["width"]) * int(a["height"])
1063 output_modes = sorted(modes[output], key=key)
1064 mode = output_modes[-1]
1065 configuration[output].options["mode"] = mode["name"]
1066 configuration[output].options["rate"] = mode["rate"]
1067 configuration[output].options["pos"] = pos_specifier % shift
1068 shift += int(mode[shift_index])
1070 configuration[output].options["off"] = None
1071 elif profile_name == "clone-largest":
1072 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1073 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1074 biggest_resolution = modes_sorted[0]
1075 for output in configuration:
1076 configuration[output].options = {}
1077 if output in modes and configuration[output].edid:
1079 score = int(a["width"]) * int(a["height"])
1083 output_modes = sorted(modes[output], key=key)
1084 mode = output_modes[-1]
1085 configuration[output].options["mode"] = mode["name"]
1086 configuration[output].options["rate"] = mode["rate"]
1087 configuration[output].options["pos"] = "0x0"
1088 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1089 float(biggest_resolution["height"]) / float(mode["height"]))
1090 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1091 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1092 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1094 configuration[output].options["off"] = None
1095 elif profile_name == "off":
1096 for output in configuration:
1097 for key in list(configuration[output].options.keys()):
1098 del configuration[output].options[key]
1099 configuration[output].options["off"] = None
1100 return configuration
1103 def print_profile_differences(one, another):
1104 "Print the differences between two profiles for debugging"
1107 print("| Differences between the two profiles:")
1108 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1109 if output not in one:
1110 if "off" not in another[output].options:
1111 print("| Output `%s' is missing from the active configuration" % output)
1112 elif output not in another:
1113 if "off" not in one[output].options:
1114 print("| Output `%s' is missing from the new configuration" % output)
1116 for line in one[output].verbose_diff(another[output]):
1117 print("| [Output %s] %s" % (output, line))
1122 "Print help and exit"
1124 for profile in virtual_profiles:
1125 name, description = profile[:2]
1126 description = [description]
1128 while len(description[0]) > max_width + 1:
1129 left_over = description[0][max_width:]
1130 description[0] = description[0][:max_width] + "-"
1131 description.insert(1, " %-15s %s" % ("", left_over))
1132 description = "\n".join(description)
1133 print(" %-15s %s" % (name, description))
1137 def exec_scripts(profile_path, script_name, meta_information=None):
1140 This will run all executables from the profile folder, and global per-user
1141 and system-wide configuration folders, named script_name or residing in
1142 subdirectories named script_name.d.
1144 If profile_path is None, only global scripts will be invoked.
1146 meta_information is expected to be an dictionary. It will be passed to the block scripts
1147 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1149 Returns True unless any of the scripts exited with non-zero exit status.
1152 env = os.environ.copy()
1153 if meta_information:
1154 for key, value in meta_information.items():
1155 env["AUTORANDR_{}".format(key.upper())] = str(value)
1157 # If there are multiple candidates, the XDG spec tells to only use the first one.
1160 user_profile_path = os.path.expanduser("~/.autorandr")
1161 if not os.path.isdir(user_profile_path):
1162 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1164 candidate_directories = []
1166 candidate_directories.append(profile_path)
1167 candidate_directories.append(user_profile_path)
1168 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1169 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1171 for folder in candidate_directories:
1172 if script_name not in ran_scripts:
1173 script = os.path.join(folder, script_name)
1174 if os.access(script, os.X_OK | os.F_OK):
1176 all_ok &= subprocess.call(script, env=env) != 0
1178 raise AutorandrException("Failed to execute user command: %s" % (script,))
1179 ran_scripts.add(script_name)
1181 script_folder = os.path.join(folder, "%s.d" % script_name)
1182 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1183 for file_name in os.listdir(script_folder):
1184 check_name = "d/%s" % (file_name,)
1185 if check_name not in ran_scripts:
1186 script = os.path.join(script_folder, file_name)
1187 if os.access(script, os.X_OK | os.F_OK):
1189 all_ok &= subprocess.call(script, env=env) != 0
1191 raise AutorandrException("Failed to execute user command: %s" % (script,))
1192 ran_scripts.add(check_name)
1197 def dispatch_call_to_sessions(argv):
1198 """Invoke autorandr for each open local X11 session with the given options.
1200 The function iterates over all processes not owned by root and checks
1201 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1202 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1203 this display has been handled already. If it has not, it forks, changes
1204 uid/gid to the user owning the process, reuses the process's environment
1205 and runs autorandr with the parameters from argv.
1207 This function requires root permissions. It only works for X11 servers that
1208 have at least one non-root process running. It is susceptible for attacks
1209 where one user runs a process with another user's DISPLAY variable - in
1210 this case, it might happen that autorandr is invoked for the other user,
1211 which won't work. Since no other harm than prevention of automated
1212 execution of autorandr can be done this way, the assumption is that in this
1213 situation, the local administrator will handle the situation."""
1215 X11_displays_done = set()
1217 autorandr_binary = os.path.abspath(argv[0])
1218 backup_candidates = {}
1220 def fork_child_autorandr(pwent, process_environ):
1221 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1222 child_pid = os.fork()
1224 # This will throw an exception if any of the privilege changes fails,
1225 # so it should be safe. Also, note that since the environment
1226 # is taken from a process owned by the user, reusing it should
1227 # not leak any information.
1229 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1230 except AttributeError:
1231 # Python 2 doesn't have getgrouplist
1233 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1234 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1235 os.chdir(pwent.pw_dir)
1237 os.environ.update(process_environ)
1238 if sys.executable != "" and sys.executable != None:
1239 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1241 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1243 os.waitpid(child_pid, 0)
1245 # The following line assumes that user accounts start at 1000 and that no
1246 # one works using the root or another system account. This is rather
1247 # restrictive, but de facto default. If this breaks your use case, set the
1248 # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1249 # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1250 # effectively, both values aren't binding in any way.)
1252 if 'AUTORANDR_UID_MIN' in os.environ:
1253 uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1255 for directory in os.listdir("/proc"):
1256 directory = os.path.join("/proc/", directory)
1257 if not os.path.isdir(directory):
1259 environ_file = os.path.join(directory, "environ")
1260 if not os.path.isfile(environ_file):
1262 uid = os.stat(environ_file).st_uid
1267 process_environ = {}
1268 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1270 environ_entry = environ_entry.decode("ascii")
1271 except UnicodeDecodeError:
1273 name, sep, value = environ_entry.partition("=")
1275 if name == "DISPLAY" and "." in value:
1276 value = value[:value.find(".")]
1277 process_environ[name] = value
1279 if "DISPLAY" not in process_environ:
1280 # Cannot work with this environment, skip.
1283 # To allow scripts to detect batch invocation (especially useful for predetect)
1284 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1285 process_environ["UID"] = str(uid)
1287 display = process_environ["DISPLAY"]
1289 if "XAUTHORITY" not in process_environ:
1290 # It's very likely that we cannot work with this environment either,
1291 # but keep it as a backup just in case we don't find anything else.
1292 backup_candidates[display] = process_environ
1295 if display not in X11_displays_done:
1297 pwent = pwd.getpwuid(uid)
1299 # User has no pwd entry
1302 fork_child_autorandr(pwent, process_environ)
1303 X11_displays_done.add(display)
1305 # Run autorandr for any users/displays which didn't have a process with
1307 for display, process_environ in backup_candidates.items():
1308 if display not in X11_displays_done:
1310 pwent = pwd.getpwuid(int(process_environ["UID"]))
1312 # User has no pwd entry
1315 fork_child_autorandr(pwent, process_environ)
1316 X11_displays_done.add(display)
1319 def enabled_monitors(config):
1321 for monitor in config:
1322 if "--off" in config[monitor].option_vector:
1324 monitors.append(monitor)
1328 def read_config(options, directory):
1329 """Parse a configuration config.ini from directory and merge it into
1330 the options dictionary"""
1331 config = configparser.ConfigParser()
1332 config.read(os.path.join(directory, "settings.ini"))
1333 if config.has_section("config"):
1334 for key, value in config.items("config"):
1335 options.setdefault("--%s" % key, value)
1339 opts, args = getopt.getopt(
1365 except getopt.GetoptError as e:
1366 print("Failed to parse options: {0}.\n"
1367 "Use --help to get usage information.".format(str(e)),
1369 sys.exit(posix.EX_USAGE)
1371 options = dict(opts)
1373 if "-h" in options or "--help" in options:
1376 if "--version" in options:
1377 print("autorandr " + __version__)
1380 if "--current" in options and "--detected" in options:
1381 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1382 sys.exit(posix.EX_USAGE)
1385 if "--batch" in options:
1386 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1387 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1389 print("--batch mode can only be used by root and if $DISPLAY is unset")
1391 if "AUTORANDR_BATCH_PID" in os.environ:
1392 user = pwd.getpwuid(os.getuid())
1393 user = user.pw_name if user else "#%d" % os.getuid()
1394 print("autorandr running as user %s (started from batch instance)" % user)
1397 profile_symlinks = {}
1399 # Load profiles from each XDG config directory
1400 # The XDG spec says that earlier entries should take precedence, so reverse the order
1401 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1402 system_profile_path = os.path.join(directory, "autorandr")
1403 if os.path.isdir(system_profile_path):
1404 profiles.update(load_profiles(system_profile_path))
1405 profile_symlinks.update(get_symlinks(system_profile_path))
1406 read_config(options, system_profile_path)
1407 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1408 # profile_path is also used later on to store configurations
1409 profile_path = os.path.expanduser("~/.autorandr")
1410 if not os.path.isdir(profile_path):
1411 # Elsewise, follow the XDG specification
1412 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1413 if os.path.isdir(profile_path):
1414 profiles.update(load_profiles(profile_path))
1415 profile_symlinks.update(get_symlinks(profile_path))
1416 read_config(options, profile_path)
1417 except Exception as e:
1418 raise AutorandrException("Failed to load profiles", e)
1420 exec_scripts(None, "predetect")
1422 ignore_lid = "--ignore-lid" in options
1424 config, modes = parse_xrandr_output(
1425 ignore_lid=ignore_lid,
1428 if "--match-edid" in options:
1429 update_profiles_edid(profiles, config)
1433 if "--cycle" in options:
1434 # When cycling through profiles, put the profile least recently used to the top of the list
1436 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1437 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}
1439 if "--fingerprint" in options:
1440 output_setup(config, sys.stdout)
1443 if "--config" in options:
1444 output_configuration(config, sys.stdout)
1447 if "--skip-options" in options:
1448 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1449 for profile in profiles.values():
1450 for output in profile["config"].values():
1451 output.set_ignored_options(skip_options)
1452 for output in config.values():
1453 output.set_ignored_options(skip_options)
1456 options["--save"] = options["-s"]
1457 if "--save" in options:
1458 if options["--save"] in (x[0] for x in virtual_profiles):
1459 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1460 "This configuration name is a reserved virtual configuration." % options["--save"])
1461 error = check_configuration_pre_save(config)
1463 print("Cannot save current configuration as profile '%s':" % options["--save"])
1467 profile_folder = os.path.join(profile_path, options["--save"])
1468 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1469 exec_scripts(profile_folder, "postsave", {
1470 "CURRENT_PROFILE": options["--save"],
1471 "PROFILE_FOLDER": profile_folder,
1472 "MONITORS": ":".join(enabled_monitors(config)),
1474 except AutorandrException as e:
1476 except Exception as e:
1477 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1478 print("Saved current configuration as profile '%s'" % options["--save"])
1482 options["--remove"] = options["-r"]
1483 if "--remove" in options:
1484 if options["--remove"] in (x[0] for x in virtual_profiles):
1485 raise AutorandrException("Cannot remove profile '%s':\n"
1486 "This configuration name is a reserved virtual configuration." % options["--remove"])
1487 if options["--remove"] not in profiles.keys():
1488 raise AutorandrException("Cannot remove profile '%s':\n"
1489 "This profile does not exist." % options["--remove"])
1492 profile_folder = os.path.join(profile_path, options["--remove"])
1493 profile_dirlist = os.listdir(profile_folder)
1494 profile_dirlist.remove("config")
1495 profile_dirlist.remove("setup")
1497 print("Profile folder '%s' contains the following additional files:\n"
1498 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1499 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1500 if response != "yes":
1503 shutil.rmtree(profile_folder)
1504 print("Removed profile '%s'" % options["--remove"])
1506 print("Profile '%s' was not removed" % options["--remove"])
1507 except Exception as e:
1508 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1511 detected_profiles = find_profiles(config, profiles)
1512 load_profile = False
1515 options["--load"] = options["-l"]
1516 if "--load" in options:
1517 load_profile = options["--load"]
1518 elif len(args) == 1:
1519 load_profile = args[0]
1521 # Find the active profile(s) first, for the block script (See #42)
1522 current_profiles = []
1523 for profile_name in profiles.keys():
1524 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1525 if configs_are_equal:
1526 current_profiles.append(profile_name)
1527 block_script_metadata = {
1528 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1529 "CURRENT_PROFILES": ":".join(current_profiles)
1533 for profile_name in profiles.keys():
1534 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1535 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1536 print("%s (blocked)" % profile_name)
1539 is_current_profile = profile_name in current_profiles
1540 if profile_name in detected_profiles:
1541 if len(detected_profiles) == 1:
1543 props.append("(detected)")
1545 index = detected_profiles.index(profile_name) + 1
1546 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1547 if index < best_index:
1548 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1549 load_profile = profile_name
1551 elif "--detected" in options:
1553 if is_current_profile:
1554 props.append("(current)")
1555 elif "--current" in options:
1557 if any(opt in options for opt in ("--current", "--detected", "--list")):
1558 print("%s" % (profile_name, ))
1560 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1561 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1562 print_profile_differences(config, profiles[profile_name]["config"])
1565 options["--default"] = options["-d"]
1566 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1567 load_profile = options["--default"]
1570 if load_profile in profile_symlinks:
1571 if "--debug" in options:
1572 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1573 load_profile = profile_symlinks[load_profile]
1575 if load_profile in (x[0] for x in virtual_profiles):
1576 load_config = generate_virtual_profile(config, modes, load_profile)
1577 scripts_path = os.path.join(profile_path, load_profile)
1580 profile = profiles[load_profile]
1581 load_config = profile["config"]
1582 scripts_path = profile["path"]
1584 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1585 if "--dry-run" not in options:
1586 update_mtime(os.path.join(scripts_path, "config"))
1587 add_unused_outputs(config, load_config)
1588 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1589 print("Config already loaded", file=sys.stderr)
1591 if "--debug" in options and load_config != dict(config):
1592 print("Loading profile '%s'" % load_profile)
1593 print_profile_differences(config, load_config)
1595 remove_irrelevant_outputs(config, load_config)
1598 if "--dry-run" in options:
1599 apply_configuration(load_config, config, True)
1602 "CURRENT_PROFILE": load_profile,
1603 "PROFILE_FOLDER": scripts_path,
1604 "MONITORS": ":".join(enabled_monitors(load_config)),
1606 exec_scripts(scripts_path, "preswitch", script_metadata)
1607 if "--debug" in options:
1608 print("Going to run:")
1609 apply_configuration(load_config, config, True)
1610 apply_configuration(load_config, config, False)
1611 exec_scripts(scripts_path, "postswitch", script_metadata)
1612 except AutorandrException as e:
1613 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1614 except Exception as e:
1615 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1617 if "--dry-run" not in options and "--debug" in options:
1618 new_config, _ = parse_xrandr_output(
1619 ignore_lid=ignore_lid,
1621 if not is_equal_configuration(new_config, load_config):
1622 print("The configuration change did not go as expected:")
1623 print_profile_differences(new_config, load_config)
1628 def exception_handled_main(argv=sys.argv):
1631 except AutorandrException as e:
1632 print(e, file=sys.stderr)
1634 except Exception as e:
1635 if not len(str(e)): # BdbQuit
1636 print("Exception: {0}".format(e.__class__.__name__))
1639 print("Unhandled exception ({0}). Please report this as a bug at "
1640 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1645 if __name__ == '__main__':
1646 exception_handled_main()