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 if self.EDID_UNAVAILABLE in self.edid:
330 # Thx to pyedid project, the following code was
331 # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
332 raw = bytes.fromhex(self.edid)
333 # Check EDID header, and checksum
334 if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
336 serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
339 # Offsets of standard timing information descriptors 1-4
340 # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
341 for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
342 if timing_bytes[0:2] == b'\x00\x00':
343 timing_type = timing_bytes[3]
344 if timing_type == 0xFF:
345 buffer = timing_bytes[5:]
346 buffer = buffer.partition(b'\x0a')[0]
347 serial_text = buffer.decode('cp437')
348 self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
350 def set_ignored_options(self, options):
351 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
352 self.ignored_options = list(options)
354 def remove_default_option_values(self):
355 "Remove values from the options dictionary that are superflous"
356 if "off" in self.options and len(self.options.keys()) > 1:
357 self.options = {"off": None}
359 for option, default_value in self.XRANDR_DEFAULTS.items():
360 if option in self.options and self.options[option] == default_value:
361 del self.options[option]
364 def from_xrandr_output(cls, xrandr_output):
365 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
367 This method also returns a list of modes supported by the output.
370 xrandr_output = xrandr_output.replace("\r\n", "\n")
371 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
373 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
376 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
377 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
379 remainder = xrandr_output[len(match_object.group(0)):]
381 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
382 "regular expression, starting at byte %d with ..'%s'." %
383 (len(remainder), len(match_object.group(0)), remainder[:10]),
386 match = match_object.groupdict()
391 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
392 if mode_match.group("name"):
393 modes.append(mode_match.groupdict())
395 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
398 if not match["connected"]:
401 edid = "".join(match["edid"].strip().split())
403 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
405 # An output can be disconnected but still have a mode configured. This can only happen
406 # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
409 # This code needs to be careful not to mix the two. An output should only be configured to
410 # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
411 if not match["width"]:
412 options["off"] = None
414 if match["mode_name"]:
415 options["mode"] = match["mode_name"]
416 elif match["mode_width"]:
417 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
419 if match["rotate"] not in ("left", "right"):
420 options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
422 options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
424 options["rotate"] = match["rotate"]
426 options["primary"] = None
427 if match["reflect"] == "X":
428 options["reflect"] = "x"
429 elif match["reflect"] == "Y":
430 options["reflect"] = "y"
431 elif match["reflect"] == "X and Y":
432 options["reflect"] = "xy"
433 if match["x"] or match["y"]:
434 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
436 panning = [match["panning"]]
437 if match["tracking"]:
438 panning += ["/", match["tracking"]]
440 panning += ["/", match["border"]]
441 options["panning"] = "".join(panning)
442 if match["transform"]:
443 transformation = ",".join(match["transform"].strip().split())
444 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
445 options["transform"] = transformation
446 if not match["mode_name"]:
447 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
448 # I doubt that this special case is actually required.
449 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
450 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
452 options["filter"] = match["filter"]
454 gamma = match["gamma"].strip()
455 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
456 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
457 # so we approximate by 1e-10.
458 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
459 options["gamma"] = gamma
461 options["crtc"] = match["crtc"]
463 options["rate"] = match["rate"]
464 for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
466 options["x-prop-" + prop] = match[prop]
468 return XrandrOutput(match["output"], edid, options), modes
471 def from_config_file(cls, profile, edid_map, configuration):
472 "Instanciate an XrandrOutput from the contents of a configuration file"
474 for line in configuration.split("\n"):
476 line = line.split(None, 1)
477 if line and line[0].startswith("#"):
479 options[line[0]] = line[1] if len(line) > 1 else None
483 if options["output"] in edid_map:
484 edid = edid_map[options["output"]]
486 # This fuzzy matching is for legacy autorandr that used sysfs output names
487 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
488 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
489 if fuzzy_output in fuzzy_edid_map:
490 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
491 elif "off" not in options:
492 raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
493 "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
494 output = options["output"]
495 del options["output"]
497 return XrandrOutput(output, edid, options)
500 def fingerprint(self):
501 return str(self.serial) if self.serial else self.short_edid
503 def fingerprint_equals(self, other):
504 if self.serial and other.serial:
505 return self.serial == other.serial
507 return self.edid_equals(other)
509 def edid_equals(self, other):
510 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
511 if self.edid and other.edid:
512 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
513 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
514 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
515 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
517 return match_asterisk(self.edid, other.edid) > 0
518 elif "*" in other.edid:
519 return match_asterisk(other.edid, self.edid) > 0
520 return self.edid == other.edid
522 def __ne__(self, other):
523 return not (self == other)
525 def __eq__(self, other):
526 return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
528 def verbose_diff(self, other):
529 "Compare to another XrandrOutput and return a list of human readable differences"
531 if not self.fingerprint_equals(other):
532 diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
533 if self.output != other.output:
534 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
535 if "off" in self.options and "off" not in other.options:
536 diffs.append("The output is disabled currently, but active in the new configuration")
537 elif "off" in other.options and "off" not in self.options:
538 diffs.append("The output is currently enabled, but inactive in the new configuration")
540 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
541 if name not in other.options:
542 diffs.append("Option --%s %sis not present in the new configuration" %
543 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
544 elif name not in self.options:
545 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
546 (name, other.options[name]))
547 elif self.options[name] != other.options[name]:
548 diffs.append("Option --%s %sis `%s' in the new configuration" %
549 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
553 def xrandr_version():
554 "Return the version of XRandR that this system uses"
555 if getattr(xrandr_version, "version", False) is False:
556 version_string = os.popen("xrandr -v").read()
558 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
559 xrandr_version.version = Version(version)
560 except AttributeError:
561 xrandr_version.version = Version("1.3.0")
563 return xrandr_version.version
566 def debug_regexp(pattern, string):
567 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
570 bounds = (0, len(string))
571 while bounds[0] != bounds[1]:
572 half = int((bounds[0] + bounds[1]) / 2)
573 if half == bounds[0]:
575 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
576 partial_length = bounds[0]
577 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
578 (partial_length, string[max(0, partial_length - 20):partial_length],
579 string[partial_length:partial_length + 10]))
582 return "Debug information would be available if the `regex' module was installed."
585 def parse_xrandr_output(
589 "Parse the output of `xrandr --verbose' into a list of outputs"
590 xrandr_output = os.popen("xrandr -q --verbose").read()
591 if not xrandr_output:
592 raise AutorandrException("Failed to run xrandr")
594 # We are not interested in screens
595 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
597 # Split at output boundaries and instanciate an XrandrOutput per output
598 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
599 if len(split_xrandr_output) < 2:
600 raise AutorandrException("No output boundaries found", report_bug=True)
601 outputs = OrderedDict()
602 modes = OrderedDict()
603 for i in range(1, len(split_xrandr_output), 2):
604 output_name = split_xrandr_output[i].split()[0]
605 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
606 outputs[output_name] = output
608 modes[output_name] = output_modes
610 # consider a closed lid as disconnected if other outputs are connected
611 if not ignore_lid and sum(
616 for output_name in outputs.keys():
617 if is_closed_lid(output_name):
618 outputs[output_name].edid = None
620 return outputs, modes
623 def load_profiles(profile_path):
624 "Load the stored profiles"
627 for profile in os.listdir(profile_path):
628 config_name = os.path.join(profile_path, profile, "config")
629 setup_name = os.path.join(profile_path, profile, "setup")
630 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
633 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
637 for line in chain(open(config_name).readlines(), ["output"]):
638 if line[:6] == "output" and buffer:
639 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
644 for output_name in list(config.keys()):
645 if config[output_name].edid is None:
646 del config[output_name]
648 profiles[profile] = {
650 "path": os.path.join(profile_path, profile),
651 "config-mtime": os.stat(config_name).st_mtime,
657 def get_symlinks(profile_path):
658 "Load all symlinks from a directory"
661 for link in os.listdir(profile_path):
662 file_name = os.path.join(profile_path, link)
663 if os.path.islink(file_name):
664 symlinks[link] = os.readlink(file_name)
669 def match_asterisk(pattern, data):
670 """Match data against a pattern
672 The difference to fnmatch is that this function only accepts patterns with a single
673 asterisk and that it returns a "closeness" number, which is larger the better the match.
674 Zero indicates no match at all.
676 if "*" not in pattern:
677 return 1 if pattern == data else 0
678 parts = pattern.split("*")
680 raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
681 if not data.startswith(parts[0]):
683 if not data.endswith(parts[1]):
685 matched = len(pattern)
686 total = len(data) + 1
687 return matched * 1. / total
690 def update_profiles_edid(profiles, config):
693 if config[c].fingerprint is not None:
694 fp_map[config[c].fingerprint] = c
697 profile_config = profiles[p]["config"]
699 for fingerprint in fp_map:
700 for c in list(profile_config.keys()):
701 if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
704 print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]))
706 tmp_disp = profile_config[c]
708 if fp_map[fingerprint] in profile_config:
709 # Swap the two entries
710 profile_config[c] = profile_config[fp_map[fingerprint]]
711 profile_config[c].output = c
713 # Object is reassigned to another key, drop this one
714 del profile_config[c]
716 profile_config[fp_map[fingerprint]] = tmp_disp
717 profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
720 def find_profiles(current_config, profiles):
721 "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
722 detected_profiles = []
723 for profile_name, profile in profiles.items():
724 config = profile["config"]
726 for name, output in config.items():
727 if not output.fingerprint:
729 if name not in current_config or not output.fingerprint_equals(current_config[name]):
732 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
735 closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
736 current_config[name].edid, output.edid))
737 detected_profiles.append((closeness, profile_name))
738 detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
739 return detected_profiles
742 def profile_blocked(profile_path, meta_information=None):
743 """Check if a profile is blocked.
745 meta_information is expected to be an dictionary. It will be passed to the block scripts
746 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
748 return not exec_scripts(profile_path, "block", meta_information)
751 def check_configuration_pre_save(configuration):
752 "Check that a configuration is safe for saving."
753 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
754 for output in outputs:
755 if "off" not in configuration[output].options and not configuration[output].edid:
756 return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
757 "This typically means that it has been recently unplugged and then not properly disabled\n"
758 "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
759 "this command.") % {"o": output}
762 def output_configuration(configuration, config):
763 "Write a configuration file"
764 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
765 for output in outputs:
766 print(configuration[output].option_string, file=config)
769 def output_setup(configuration, setup):
770 "Write a setup (fingerprint) file"
771 outputs = sorted(configuration.keys())
772 for output in outputs:
773 if configuration[output].edid:
774 print(output, configuration[output].edid, file=setup)
777 def save_configuration(profile_path, profile_name, configuration, forced=False):
778 "Save a configuration into a profile"
779 if not os.path.isdir(profile_path):
780 os.makedirs(profile_path)
781 config_path = os.path.join(profile_path, "config")
782 setup_path = os.path.join(profile_path, "setup")
783 if os.path.isfile(config_path) and not forced:
784 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
785 if os.path.isfile(setup_path) and not forced:
786 raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
788 with open(config_path, "w") as config:
789 output_configuration(configuration, config)
790 with open(setup_path, "w") as setup:
791 output_setup(configuration, setup)
794 def update_mtime(filename):
795 "Update a file's mtime"
797 os.utime(filename, None)
803 def call_and_retry(*args, **kwargs):
804 """Wrapper around subprocess.call that retries failed calls.
806 This function calls subprocess.call and on non-zero exit states,
807 waits a second and then retries once. This mitigates #47,
808 a timing issue with some drivers.
810 if kwargs.pop("dry_run", False):
812 print(shlex.quote(arg), end=" ")
816 if hasattr(subprocess, "DEVNULL"):
817 kwargs["stdout"] = getattr(subprocess, "DEVNULL")
819 kwargs["stdout"] = open(os.devnull, "w")
820 kwargs["stderr"] = kwargs["stdout"]
821 retval = subprocess.call(*args, **kwargs)
824 retval = subprocess.call(*args, **kwargs)
828 def get_fb_dimensions(configuration):
831 for output in configuration.values():
832 if "off" in output.options or not output.edid:
834 # This won't work with all modes -- but it's a best effort.
835 match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
838 o_mode = match.group(0)
839 o_width, o_height = map(int, o_mode.split("x"))
840 if "transform" in output.options:
841 a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
842 w = (g * o_width + h * o_height + i)
843 x = (a * o_width + b * o_height + c) / w
844 y = (d * o_width + e * o_height + f) / w
845 o_width, o_height = x, y
846 if "rotate" in output.options:
847 if output.options["rotate"] in ("left", "right"):
848 o_width, o_height = o_height, o_width
849 if "pos" in output.options:
850 o_left, o_top = map(int, output.options["pos"].split("x"))
853 if "panning" in output.options:
854 match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
856 detail = match.groupdict(default="0")
857 o_width = int(detail.get("w")) + int(detail.get("x"))
858 o_height = int(detail.get("h")) + int(detail.get("y"))
859 width = max(width, o_width)
860 height = max(height, o_height)
861 return math.ceil(width), math.ceil(height)
864 def apply_configuration(new_configuration, current_configuration, dry_run=False):
865 "Apply a configuration"
866 found_top_left_monitor = False
867 found_left_monitor = False
868 found_top_monitor = False
869 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
870 base_argv = ["xrandr"]
872 # There are several xrandr / driver bugs we need to take care of here:
873 # - We cannot enable more than two screens at the same time
874 # See https://github.com/phillipberndt/autorandr/pull/6
875 # and commits f4cce4d and 8429886.
876 # - We cannot disable all screens
877 # See https://github.com/phillipberndt/autorandr/pull/20
878 # - We should disable screens before enabling others, because there's
879 # a limit on the number of enabled screens
880 # - We must make sure that the screen at 0x0 is activated first,
881 # or the other (first) screen to be activated would be moved there.
882 # - If an active screen already has a transformation and remains active,
883 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
884 # Update the configuration in 3 passes in that case. (On Haswell graphics,
886 # - Some implementations can not handle --transform at all, so avoid it unless
887 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
888 # - Some implementations can not handle --panning without specifying --fb
889 # explicitly, so avoid it unless necessary.
890 # (See https://github.com/phillipberndt/autorandr/issues/72)
892 fb_dimensions = get_fb_dimensions(new_configuration)
894 fb_args = ["--fb", "%dx%d" % fb_dimensions]
896 # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
899 auxiliary_changes_pre = []
902 remain_active_count = 0
903 for output in outputs:
904 if not new_configuration[output].edid or "off" in new_configuration[output].options:
905 disable_outputs.append(new_configuration[output].option_vector)
907 if output not in current_configuration:
908 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
909 "Don't know how to proceed." % output)
910 if "off" not in current_configuration[output].options:
911 remain_active_count += 1
913 option_vector = new_configuration[output].option_vector
914 if xrandr_version() >= Version("1.3.0"):
915 for option, off_value in (("transform", "none"), ("panning", "0x0")):
916 if option in current_configuration[output].options:
917 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
920 option_index = option_vector.index("--%s" % option)
921 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
922 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
925 if not found_top_left_monitor:
926 position = new_configuration[output].options.get("pos", "0x0")
927 if position == "0x0":
928 found_top_left_monitor = True
929 enable_outputs.insert(0, option_vector)
930 elif not found_left_monitor and position.startswith("0x"):
931 found_left_monitor = True
932 enable_outputs.insert(0, option_vector)
933 elif not found_top_monitor and position.endswith("x0"):
934 found_top_monitor = True
935 enable_outputs.insert(0, option_vector)
937 enable_outputs.append(option_vector)
939 enable_outputs.append(option_vector)
941 # Perform pe-change auxiliary changes
942 if auxiliary_changes_pre:
943 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
944 if call_and_retry(argv, dry_run=dry_run) != 0:
945 raise AutorandrException("Command failed: %s" % " ".join(argv))
947 # Starting here, fix the frame buffer size
948 # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
949 # dimensions larger than they will finally be.
952 # Disable unused outputs, but make sure that there always is at least one active screen
953 disable_keep = 0 if remain_active_count else 1
954 if len(disable_outputs) > disable_keep:
955 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
956 if call_and_retry(argv, dry_run=dry_run) != 0:
957 # Disabling the outputs failed. Retry with the next command:
958 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
959 # This does not occur if simultaneously the primary screen is reset.
962 disable_outputs = disable_outputs[-1:] if disable_keep else []
964 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
965 # disable the last two screens. This is a problem, so if this would happen, instead disable only
966 # one screen in the first call below.
967 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
968 # In the context of a xrandr call that changes the display state, `--query' should do nothing
969 disable_outputs.insert(0, ['--query'])
971 # If we did not find a candidate, we might need to inject a call
972 # If there is no output to disable, we will enable 0x and x0 at the same time
973 if not found_top_left_monitor and len(disable_outputs) > 0:
974 # If the call to 0x and x0 is splitted, inject one of them
975 if found_top_monitor and found_left_monitor:
976 enable_outputs.insert(0, enable_outputs[0])
978 # Enable the remaining outputs in pairs of two operations
979 operations = disable_outputs + enable_outputs
980 for index in range(0, len(operations), 2):
981 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
982 if call_and_retry(argv, dry_run=dry_run) != 0:
983 raise AutorandrException("Command failed: %s" % " ".join(argv))
986 def is_equal_configuration(source_configuration, target_configuration):
988 Check if all outputs from target are already configured correctly in source and
989 that no other outputs are active.
991 for output in target_configuration.keys():
992 if "off" in target_configuration[output].options:
993 if (output in source_configuration and "off" not in source_configuration[output].options):
996 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
998 for output in source_configuration.keys():
999 if "off" in source_configuration[output].options:
1000 if output in target_configuration and "off" not in target_configuration[output].options:
1003 if output not in target_configuration:
1008 def add_unused_outputs(source_configuration, target_configuration):
1009 "Add outputs that are missing in target to target, in 'off' state"
1010 for output_name, output in source_configuration.items():
1011 if output_name not in target_configuration:
1012 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1015 def remove_irrelevant_outputs(source_configuration, target_configuration):
1016 "Remove outputs from target that ought to be 'off' and already are"
1017 for output_name, output in source_configuration.items():
1018 if "off" in output.options:
1019 if output_name in target_configuration:
1020 if "off" in target_configuration[output_name].options:
1021 del target_configuration[output_name]
1024 def generate_virtual_profile(configuration, modes, profile_name):
1025 "Generate one of the virtual profiles"
1026 configuration = copy.deepcopy(configuration)
1027 if profile_name == "common":
1029 for output, output_modes in modes.items():
1031 if configuration[output].edid:
1032 for mode in output_modes:
1033 mode_set.add((mode["width"], mode["height"]))
1034 mode_sets.append(mode_set)
1035 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1036 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1037 if common_resolution:
1038 for output in configuration:
1039 configuration[output].options = {}
1040 if output in modes and configuration[output].edid:
1041 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1042 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1043 mode = modes_filtered[0]
1044 configuration[output].options["mode"] = mode['name']
1045 configuration[output].options["pos"] = "0x0"
1047 configuration[output].options["off"] = None
1048 elif profile_name in ("horizontal", "vertical"):
1050 if profile_name == "horizontal":
1051 shift_index = "width"
1052 pos_specifier = "%sx0"
1054 shift_index = "height"
1055 pos_specifier = "0x%s"
1057 for output in configuration:
1058 configuration[output].options = {}
1059 if output in modes and configuration[output].edid:
1061 score = int(a["width"]) * int(a["height"])
1065 output_modes = sorted(modes[output], key=key)
1066 mode = output_modes[-1]
1067 configuration[output].options["mode"] = mode["name"]
1068 configuration[output].options["rate"] = mode["rate"]
1069 configuration[output].options["pos"] = pos_specifier % shift
1070 shift += int(mode[shift_index])
1072 configuration[output].options["off"] = None
1073 elif profile_name == "clone-largest":
1074 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1075 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1076 biggest_resolution = modes_sorted[0]
1077 for output in configuration:
1078 configuration[output].options = {}
1079 if output in modes and configuration[output].edid:
1081 score = int(a["width"]) * int(a["height"])
1085 output_modes = sorted(modes[output], key=key)
1086 mode = output_modes[-1]
1087 configuration[output].options["mode"] = mode["name"]
1088 configuration[output].options["rate"] = mode["rate"]
1089 configuration[output].options["pos"] = "0x0"
1090 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1091 float(biggest_resolution["height"]) / float(mode["height"]))
1092 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1093 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1094 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1096 configuration[output].options["off"] = None
1097 elif profile_name == "off":
1098 for output in configuration:
1099 for key in list(configuration[output].options.keys()):
1100 del configuration[output].options[key]
1101 configuration[output].options["off"] = None
1102 return configuration
1105 def print_profile_differences(one, another):
1106 "Print the differences between two profiles for debugging"
1109 print("| Differences between the two profiles:")
1110 for output in set(chain.from_iterable((one.keys(), another.keys()))):
1111 if output not in one:
1112 if "off" not in another[output].options:
1113 print("| Output `%s' is missing from the active configuration" % output)
1114 elif output not in another:
1115 if "off" not in one[output].options:
1116 print("| Output `%s' is missing from the new configuration" % output)
1118 for line in one[output].verbose_diff(another[output]):
1119 print("| [Output %s] %s" % (output, line))
1124 "Print help and exit"
1126 for profile in virtual_profiles:
1127 name, description = profile[:2]
1128 description = [description]
1130 while len(description[0]) > max_width + 1:
1131 left_over = description[0][max_width:]
1132 description[0] = description[0][:max_width] + "-"
1133 description.insert(1, " %-15s %s" % ("", left_over))
1134 description = "\n".join(description)
1135 print(" %-15s %s" % (name, description))
1139 def exec_scripts(profile_path, script_name, meta_information=None):
1142 This will run all executables from the profile folder, and global per-user
1143 and system-wide configuration folders, named script_name or residing in
1144 subdirectories named script_name.d.
1146 If profile_path is None, only global scripts will be invoked.
1148 meta_information is expected to be an dictionary. It will be passed to the block scripts
1149 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1151 Returns True unless any of the scripts exited with non-zero exit status.
1154 env = os.environ.copy()
1155 if meta_information:
1156 for key, value in meta_information.items():
1157 env["AUTORANDR_{}".format(key.upper())] = str(value)
1159 # If there are multiple candidates, the XDG spec tells to only use the first one.
1162 user_profile_path = os.path.expanduser("~/.autorandr")
1163 if not os.path.isdir(user_profile_path):
1164 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1166 candidate_directories = []
1168 candidate_directories.append(profile_path)
1169 candidate_directories.append(user_profile_path)
1170 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1171 candidate_directories.append(os.path.join(config_dir, "autorandr"))
1173 for folder in candidate_directories:
1174 if script_name not in ran_scripts:
1175 script = os.path.join(folder, script_name)
1176 if os.access(script, os.X_OK | os.F_OK):
1178 all_ok &= subprocess.call(script, env=env) != 0
1180 raise AutorandrException("Failed to execute user command: %s" % (script,))
1181 ran_scripts.add(script_name)
1183 script_folder = os.path.join(folder, "%s.d" % script_name)
1184 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1185 for file_name in os.listdir(script_folder):
1186 check_name = "d/%s" % (file_name,)
1187 if check_name not in ran_scripts:
1188 script = os.path.join(script_folder, file_name)
1189 if os.access(script, os.X_OK | os.F_OK):
1191 all_ok &= subprocess.call(script, env=env) != 0
1193 raise AutorandrException("Failed to execute user command: %s" % (script,))
1194 ran_scripts.add(check_name)
1199 def dispatch_call_to_sessions(argv):
1200 """Invoke autorandr for each open local X11 session with the given options.
1202 The function iterates over all processes not owned by root and checks
1203 whether they have DISPLAY and XAUTHORITY variables set. It strips the
1204 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1205 this display has been handled already. If it has not, it forks, changes
1206 uid/gid to the user owning the process, reuses the process's environment
1207 and runs autorandr with the parameters from argv.
1209 This function requires root permissions. It only works for X11 servers that
1210 have at least one non-root process running. It is susceptible for attacks
1211 where one user runs a process with another user's DISPLAY variable - in
1212 this case, it might happen that autorandr is invoked for the other user,
1213 which won't work. Since no other harm than prevention of automated
1214 execution of autorandr can be done this way, the assumption is that in this
1215 situation, the local administrator will handle the situation."""
1217 X11_displays_done = set()
1219 autorandr_binary = os.path.abspath(argv[0])
1220 backup_candidates = {}
1222 def fork_child_autorandr(pwent, process_environ):
1223 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1224 child_pid = os.fork()
1226 # This will throw an exception if any of the privilege changes fails,
1227 # so it should be safe. Also, note that since the environment
1228 # is taken from a process owned by the user, reusing it should
1229 # not leak any information.
1231 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1232 except AttributeError:
1233 # Python 2 doesn't have getgrouplist
1235 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1236 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1237 os.chdir(pwent.pw_dir)
1239 os.environ.update(process_environ)
1240 if sys.executable != "" and sys.executable != None:
1241 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1243 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1245 os.waitpid(child_pid, 0)
1247 # The following line assumes that user accounts start at 1000 and that no
1248 # one works using the root or another system account. This is rather
1249 # restrictive, but de facto default. If this breaks your use case, set the
1250 # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1251 # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1252 # effectively, both values aren't binding in any way.)
1254 if 'AUTORANDR_UID_MIN' in os.environ:
1255 uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1257 for directory in os.listdir("/proc"):
1258 directory = os.path.join("/proc/", directory)
1259 if not os.path.isdir(directory):
1261 environ_file = os.path.join(directory, "environ")
1262 if not os.path.isfile(environ_file):
1264 uid = os.stat(environ_file).st_uid
1269 process_environ = {}
1270 for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1272 environ_entry = environ_entry.decode("ascii")
1273 except UnicodeDecodeError:
1275 name, sep, value = environ_entry.partition("=")
1277 if name == "DISPLAY" and "." in value:
1278 value = value[:value.find(".")]
1279 process_environ[name] = value
1281 if "DISPLAY" not in process_environ:
1282 # Cannot work with this environment, skip.
1285 # To allow scripts to detect batch invocation (especially useful for predetect)
1286 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1287 process_environ["UID"] = str(uid)
1289 display = process_environ["DISPLAY"]
1291 if "XAUTHORITY" not in process_environ:
1292 # It's very likely that we cannot work with this environment either,
1293 # but keep it as a backup just in case we don't find anything else.
1294 backup_candidates[display] = process_environ
1297 if display not in X11_displays_done:
1299 pwent = pwd.getpwuid(uid)
1301 # User has no pwd entry
1304 fork_child_autorandr(pwent, process_environ)
1305 X11_displays_done.add(display)
1307 # Run autorandr for any users/displays which didn't have a process with
1309 for display, process_environ in backup_candidates.items():
1310 if display not in X11_displays_done:
1312 pwent = pwd.getpwuid(int(process_environ["UID"]))
1314 # User has no pwd entry
1317 fork_child_autorandr(pwent, process_environ)
1318 X11_displays_done.add(display)
1321 def enabled_monitors(config):
1323 for monitor in config:
1324 if "--off" in config[monitor].option_vector:
1326 monitors.append(monitor)
1330 def read_config(options, directory):
1331 """Parse a configuration config.ini from directory and merge it into
1332 the options dictionary"""
1333 config = configparser.ConfigParser()
1334 config.read(os.path.join(directory, "settings.ini"))
1335 if config.has_section("config"):
1336 for key, value in config.items("config"):
1337 options.setdefault("--%s" % key, value)
1341 opts, args = getopt.getopt(
1367 except getopt.GetoptError as e:
1368 print("Failed to parse options: {0}.\n"
1369 "Use --help to get usage information.".format(str(e)),
1371 sys.exit(posix.EX_USAGE)
1373 options = dict(opts)
1375 if "-h" in options or "--help" in options:
1378 if "--version" in options:
1379 print("autorandr " + __version__)
1382 if "--current" in options and "--detected" in options:
1383 print("--current and --detected are mutually exclusive.", file=sys.stderr)
1384 sys.exit(posix.EX_USAGE)
1387 if "--batch" in options:
1388 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1389 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1391 print("--batch mode can only be used by root and if $DISPLAY is unset")
1393 if "AUTORANDR_BATCH_PID" in os.environ:
1394 user = pwd.getpwuid(os.getuid())
1395 user = user.pw_name if user else "#%d" % os.getuid()
1396 print("autorandr running as user %s (started from batch instance)" % user)
1399 profile_symlinks = {}
1401 # Load profiles from each XDG config directory
1402 # The XDG spec says that earlier entries should take precedence, so reverse the order
1403 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1404 system_profile_path = os.path.join(directory, "autorandr")
1405 if os.path.isdir(system_profile_path):
1406 profiles.update(load_profiles(system_profile_path))
1407 profile_symlinks.update(get_symlinks(system_profile_path))
1408 read_config(options, system_profile_path)
1409 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1410 # profile_path is also used later on to store configurations
1411 profile_path = os.path.expanduser("~/.autorandr")
1412 if not os.path.isdir(profile_path):
1413 # Elsewise, follow the XDG specification
1414 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1415 if os.path.isdir(profile_path):
1416 profiles.update(load_profiles(profile_path))
1417 profile_symlinks.update(get_symlinks(profile_path))
1418 read_config(options, profile_path)
1419 except Exception as e:
1420 raise AutorandrException("Failed to load profiles", e)
1422 exec_scripts(None, "predetect")
1424 ignore_lid = "--ignore-lid" in options
1426 config, modes = parse_xrandr_output(
1427 ignore_lid=ignore_lid,
1430 if "--match-edid" in options:
1431 update_profiles_edid(profiles, config)
1435 if "--cycle" in options:
1436 # When cycling through profiles, put the profile least recently used to the top of the list
1438 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1439 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}
1441 if "--fingerprint" in options:
1442 output_setup(config, sys.stdout)
1445 if "--config" in options:
1446 output_configuration(config, sys.stdout)
1449 if "--skip-options" in options:
1450 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1451 for profile in profiles.values():
1452 for output in profile["config"].values():
1453 output.set_ignored_options(skip_options)
1454 for output in config.values():
1455 output.set_ignored_options(skip_options)
1458 options["--save"] = options["-s"]
1459 if "--save" in options:
1460 if options["--save"] in (x[0] for x in virtual_profiles):
1461 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1462 "This configuration name is a reserved virtual configuration." % options["--save"])
1463 error = check_configuration_pre_save(config)
1465 print("Cannot save current configuration as profile '%s':" % options["--save"])
1469 profile_folder = os.path.join(profile_path, options["--save"])
1470 save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1471 exec_scripts(profile_folder, "postsave", {
1472 "CURRENT_PROFILE": options["--save"],
1473 "PROFILE_FOLDER": profile_folder,
1474 "MONITORS": ":".join(enabled_monitors(config)),
1476 except AutorandrException as e:
1478 except Exception as e:
1479 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1480 print("Saved current configuration as profile '%s'" % options["--save"])
1484 options["--remove"] = options["-r"]
1485 if "--remove" in options:
1486 if options["--remove"] in (x[0] for x in virtual_profiles):
1487 raise AutorandrException("Cannot remove profile '%s':\n"
1488 "This configuration name is a reserved virtual configuration." % options["--remove"])
1489 if options["--remove"] not in profiles.keys():
1490 raise AutorandrException("Cannot remove profile '%s':\n"
1491 "This profile does not exist." % options["--remove"])
1494 profile_folder = os.path.join(profile_path, options["--remove"])
1495 profile_dirlist = os.listdir(profile_folder)
1496 profile_dirlist.remove("config")
1497 profile_dirlist.remove("setup")
1499 print("Profile folder '%s' contains the following additional files:\n"
1500 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1501 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1502 if response != "yes":
1505 shutil.rmtree(profile_folder)
1506 print("Removed profile '%s'" % options["--remove"])
1508 print("Profile '%s' was not removed" % options["--remove"])
1509 except Exception as e:
1510 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1513 detected_profiles = find_profiles(config, profiles)
1514 load_profile = False
1517 options["--load"] = options["-l"]
1518 if "--load" in options:
1519 load_profile = options["--load"]
1520 elif len(args) == 1:
1521 load_profile = args[0]
1523 # Find the active profile(s) first, for the block script (See #42)
1524 current_profiles = []
1525 for profile_name in profiles.keys():
1526 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1527 if configs_are_equal:
1528 current_profiles.append(profile_name)
1529 block_script_metadata = {
1530 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1531 "CURRENT_PROFILES": ":".join(current_profiles)
1535 for profile_name in profiles.keys():
1536 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1537 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1538 print("%s (blocked)" % profile_name)
1541 is_current_profile = profile_name in current_profiles
1542 if profile_name in detected_profiles:
1543 if len(detected_profiles) == 1:
1545 props.append("(detected)")
1547 index = detected_profiles.index(profile_name) + 1
1548 props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1549 if index < best_index:
1550 if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1551 load_profile = profile_name
1553 elif "--detected" in options:
1555 if is_current_profile:
1556 props.append("(current)")
1557 elif "--current" in options:
1559 if any(opt in options for opt in ("--current", "--detected", "--list")):
1560 print("%s" % (profile_name, ))
1562 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1563 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1564 print_profile_differences(config, profiles[profile_name]["config"])
1567 options["--default"] = options["-d"]
1568 if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1569 load_profile = options["--default"]
1572 if load_profile in profile_symlinks:
1573 if "--debug" in options:
1574 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1575 load_profile = profile_symlinks[load_profile]
1577 if load_profile in (x[0] for x in virtual_profiles):
1578 load_config = generate_virtual_profile(config, modes, load_profile)
1579 scripts_path = os.path.join(profile_path, load_profile)
1582 profile = profiles[load_profile]
1583 load_config = profile["config"]
1584 scripts_path = profile["path"]
1586 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1587 if "--dry-run" not in options:
1588 update_mtime(os.path.join(scripts_path, "config"))
1589 add_unused_outputs(config, load_config)
1590 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1591 print("Config already loaded", file=sys.stderr)
1593 if "--debug" in options and load_config != dict(config):
1594 print("Loading profile '%s'" % load_profile)
1595 print_profile_differences(config, load_config)
1597 remove_irrelevant_outputs(config, load_config)
1600 if "--dry-run" in options:
1601 apply_configuration(load_config, config, True)
1604 "CURRENT_PROFILE": load_profile,
1605 "PROFILE_FOLDER": scripts_path,
1606 "MONITORS": ":".join(enabled_monitors(load_config)),
1608 exec_scripts(scripts_path, "preswitch", script_metadata)
1609 if "--debug" in options:
1610 print("Going to run:")
1611 apply_configuration(load_config, config, True)
1612 apply_configuration(load_config, config, False)
1613 exec_scripts(scripts_path, "postswitch", script_metadata)
1614 except AutorandrException as e:
1615 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1616 except Exception as e:
1617 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1619 if "--dry-run" not in options and "--debug" in options:
1620 new_config, _ = parse_xrandr_output(
1621 ignore_lid=ignore_lid,
1623 if not is_equal_configuration(new_config, load_config):
1624 print("The configuration change did not go as expected:")
1625 print_profile_differences(new_config, load_config)
1630 def exception_handled_main(argv=sys.argv):
1633 except AutorandrException as e:
1634 print(e, file=sys.stderr)
1636 except Exception as e:
1637 if not len(str(e)): # BdbQuit
1638 print("Exception: {0}".format(e.__class__.__name__))
1641 print("Unhandled exception ({0}). Please report this as a bug at "
1642 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1647 if __name__ == '__main__':
1648 exception_handled_main()