5 # Copyright (c) 2015, Phillip Berndt
7 # Experimental 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
35 from distutils.version import LooseVersion as Version
37 from itertools import chain
38 from collections import OrderedDict
41 # (name, description, callback)
42 ("common", "Clone all connected outputs at the largest common resolution", None),
43 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
44 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
48 Usage: autorandr [options]
50 -h, --help get this small help
51 -c, --change reload current setup
52 -s, --save <profile> save your current setup to profile <profile>
53 -l, --load <profile> load profile <profile>
54 -d, --default <profile> make profile <profile> the default profile
55 --force force (re)loading of a profile
56 --fingerprint fingerprint your current hardware setup
57 --config dump your current xrandr setup
58 --dry-run don't change anything, only print the xrandr commands
60 To prevent a profile from being loaded, place a script call "block" in its
61 directory. The script is evaluated before the screen setup is inspected, and
62 in case of it returning a value of 0 the profile is skipped. This can be used
63 to query the status of a docking station you are about to leave.
65 If no suitable profile can be identified, the current configuration is kept.
66 To change this behaviour and switch to a fallback configuration, specify
69 Another script called "postswitch "can be placed in the directory
70 ~/.autorandr as well as in any profile directories: The scripts are executed
71 after a mode switch has taken place and can notify window managers.
73 The following virtual configurations are available:
76 class XrandrOutput(object):
77 "Represents an XRandR output"
79 # This regular expression is used to parse an output in `xrandr --verbose'
80 XRANDR_OUTPUT_REGEXP = """(?x)
81 ^(?P<output>[^ ]+)\s+ # Line starts with output name
82 (?: # Differentiate disconnected and connected in first line
85 (?P<connected>connected)\s+ # If connected:
87 (?P<primary>primary\ )? # Might be primary screen
88 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
89 \+(?P<x>[0-9]+)\+(?P<y>[0-9]+)\s+ # Position
90 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
91 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
92 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
93 )? # .. but everything of the above only if the screen is in use.
95 (?:\s*(?: # Properties of the output
96 Gamma: (?P<gamma>[0-9\.:\s]+) | # Gamma value
97 Transform: (?P<transform>[0-9\.\s]+) | # Transformation matrix
98 EDID: (?P<edid>[0-9a-f\s]+) | # EDID of the output
99 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
103 (?P<mode_width>[0-9]+)x(?P<mode_height>[0-9]+).+?\*current.+\s+
104 h:.+\s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz\s* | # Interesting (current) resolution: Extract rate
105 [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s* # Other resolutions
109 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
110 (?P<width>[0-9]+)x(?P<height>[0-9]+)
111 .*?(?P<preferred>\+preferred)?
113 \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
116 XRANDR_13_DEFAULTS = {
117 "transform": "1,0,0,0,1,0,0,0,1",
120 XRANDR_12_DEFAULTS = {
123 "gamma": "1.0:1.0:1.0",
126 XRANDR_DEFAULTS = dict(XRANDR_13_DEFAULTS.items() + XRANDR_12_DEFAULTS.items())
129 return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
132 def options_with_defaults(self):
133 "Return the options dictionary, augmented with the default values that weren't set"
134 if "off" in self.options:
137 if xrandr_version() >= Version("1.3"):
138 options.update(self.XRANDR_13_DEFAULTS)
139 if xrandr_version() >= Version("1.2"):
140 options.update(self.XRANDR_12_DEFAULTS)
141 options.update(self.options)
145 def option_vector(self):
146 "Return the command line parameters for XRandR for this instance"
147 return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), self.options_with_defaults.items())], [])
150 def option_string(self):
151 "Return the command line parameters in the configuration file format"
152 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
156 "Return a key to sort the outputs for xrandr invocation"
159 if "pos" in self.options:
160 x, y = map(float, self.options["pos"].split("x"))
165 def __init__(self, output, edid, options):
166 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
169 self.options = options
170 self.remove_default_option_values()
172 def remove_default_option_values(self):
173 "Remove values from the options dictionary that are superflous"
174 if "off" in self.options and len(self.options.keys()) > 1:
175 self.options = { "off": None }
177 for option, default_value in self.XRANDR_DEFAULTS.items():
178 if option in self.options and self.options[option] == default_value:
179 del self.options[option]
182 def from_xrandr_output(cls, xrandr_output):
183 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
185 This method also returns a list of modes supported by the output.
188 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
190 raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
192 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
193 raise RuntimeError("Parsing XRandR output failed, the regular expression did not match: %s" % debug)
194 remainder = xrandr_output[len(match_object.group(0)):]
196 raise RuntimeError(("Parsing XRandR output failed, %d bytes left unmatched after regular expression,"
197 "starting with ..'%s'.") % (len(remainder), remainder[:10]))
200 match = match_object.groupdict()
204 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
206 raise RuntimeError("Parsing XRandR output failed, couldn't find any display modes")
209 if not match["connected"]:
210 options["off"] = None
212 elif not match["width"]:
213 options["off"] = None
214 edid = "".join(match["edid"].strip().split())
216 if match["mode_width"]:
217 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
219 if match["rotate"] not in ("left", "right"):
220 options["mode"] = "%sx%s" % (match["width"], match["height"])
222 options["mode"] = "%sx%s" % (match["height"], match["width"])
223 options["rotate"] = match["rotate"]
225 options["primary"] = None
226 if match["reflect"] == "X":
227 options["reflect"] = "x"
228 elif match["reflect"] == "Y":
229 options["reflect"] = "y"
230 elif match["reflect"] == "X and Y":
231 options["reflect"] = "xy"
232 options["pos"] = "%sx%s" % (match["x"], match["y"])
233 if match["transform"]:
234 transformation = ",".join(match["transform"].strip().split())
235 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
236 options["transform"] = transformation
237 if not match["mode_width"]:
238 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
239 # special case is actually required.
240 print("Warning: Output %s has a transformation applied. Could not determine correct mode!", file=sys.stderr)
242 gamma = match["gamma"].strip()
243 options["gamma"] = gamma
245 options["rate"] = match["rate"]
246 edid = "".join(match["edid"].strip().split())
248 return XrandrOutput(match["output"], edid, options), modes
251 def from_config_file(cls, edid_map, configuration):
252 "Instanciate an XrandrOutput from the contents of a configuration file"
254 for line in configuration.split("\n"):
256 line = line.split(None, 1)
257 options[line[0]] = line[1] if len(line) > 1 else None
261 if options["output"] in edid_map:
262 edid = edid_map[options["output"]]
264 # This fuzzy matching is for legacy autorandr that used sysfs output names
265 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
266 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
267 if fuzzy_output in fuzzy_edid_map:
268 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
269 elif "off" not in options:
270 raise RuntimeError("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file."
271 % (options["output"], options["output"]))
272 output = options["output"]
273 del options["output"]
275 return XrandrOutput(output, edid, options)
277 def edid_equals(self, other):
278 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
279 if self.edid and other.edid:
280 if len(self.edid) == 32 and len(other.edid) != 32:
281 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
282 if len(self.edid) != 32 and len(other.edid) == 32:
283 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
284 return self.edid == other.edid
286 def __eq__(self, other):
287 return self.edid_equals(other) and self.output == other.output and self.options == other.options
289 def xrandr_version():
290 "Return the version of XRandR that this system uses"
291 if getattr(xrandr_version, "version", False) is False:
292 version_string = os.popen("xrandr -v").read()
294 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
295 xrandr_version.version = Version(version)
296 except AttributeError:
297 xrandr_version.version = Version("1.3.0")
299 return xrandr_version.version
301 def debug_regexp(pattern, string):
302 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
305 bounds = ( 0, len(string) )
306 while bounds[0] != bounds[1]:
307 half = int((bounds[0] + bounds[1]) / 2)
308 if half == bounds[0]:
310 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
311 partial_length = bounds[0]
312 return ("Regular expression matched until position "
313 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
314 string[partial_length:partial_length+10]))
317 return "Debug information available if `regex' module is installed."
319 def parse_xrandr_output():
320 "Parse the output of `xrandr --verbose' into a list of outputs"
321 xrandr_output = os.popen("xrandr -q --verbose").read()
322 if not xrandr_output:
323 raise RuntimeError("Failed to run xrandr")
325 # We are not interested in screens
326 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
328 # Split at output boundaries and instanciate an XrandrOutput per output
329 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
330 outputs = OrderedDict()
331 modes = OrderedDict()
332 for i in range(1, len(split_xrandr_output), 2):
333 output_name = split_xrandr_output[i].split()[0]
334 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
335 outputs[output_name] = output
337 modes[output_name] = output_modes
339 return outputs, modes
341 def load_profiles(profile_path):
342 "Load the stored profiles"
345 for profile in os.listdir(profile_path):
346 config_name = os.path.join(profile_path, profile, "config")
347 setup_name = os.path.join(profile_path, profile, "setup")
348 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
351 edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
355 for line in chain(open(config_name).readlines(), ["output"]):
356 if line[:6] == "output" and buffer:
357 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
362 for output_name in list(config.keys()):
363 if config[output_name].edid is None:
364 del config[output_name]
366 profiles[profile] = config
370 def find_profile(current_config, profiles):
371 "Find a profile matching the currently connected outputs"
372 for profile_name, profile in profiles.items():
374 for name, output in profile.items():
377 if name not in current_config or not output.edid_equals(current_config[name]):
380 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
385 def profile_blocked(profile_path):
386 "Check if a profile is blocked"
387 script = os.path.join(profile_path, "blocked")
388 if not os.access(script, os.X_OK | os.F_OK):
390 return subprocess.call(script) == 0
392 def output_configuration(configuration, config):
393 "Write a configuration file"
394 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
395 for output in outputs:
396 print(configuration[output].option_string, file=config)
398 def output_setup(configuration, setup):
399 "Write a setup (fingerprint) file"
400 outputs = sorted(configuration.keys())
401 for output in outputs:
402 if configuration[output].edid:
403 print(output, configuration[output].edid, file=setup)
405 def save_configuration(profile_path, configuration):
406 "Save a configuration into a profile"
407 if not os.path.isdir(profile_path):
408 os.makedirs(profile_path)
409 with open(os.path.join(profile_path, "config"), "w") as config:
410 output_configuration(configuration, config)
411 with open(os.path.join(profile_path, "setup"), "w") as setup:
412 output_setup(configuration, setup)
414 def apply_configuration(configuration, dry_run=False):
415 "Apply a configuration"
416 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
418 base_argv = [ "echo", "xrandr" ]
420 base_argv = [ "xrandr" ]
422 # Disable all unused outputs
424 for output in outputs:
425 if not configuration[output].edid:
426 argv += configuration[output].option_vector
427 if argv != base_argv:
428 if subprocess.call(argv) != 0:
431 # Enable remaining outputs in pairs of two
432 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
433 for index in range(0, len(remaining_outputs), 2):
434 if subprocess.call((base_argv[:] + configuration[remaining_outputs[index]].option_vector + (configuration[remaining_outputs[index + 1]].option_vector if index < len(remaining_outputs) - 1 else []))) != 0:
437 def add_unused_outputs(source_configuration, target_configuration):
438 "Add outputs that are missing in target to target, in 'off' state"
439 for output_name, output in source_configuration.items():
440 if output_name not in target_configuration:
441 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
443 def generate_virtual_profile(configuration, modes, profile_name):
444 "Generate one of the virtual profiles"
445 configuration = copy.deepcopy(configuration)
446 if profile_name == "common":
447 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
448 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
449 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
450 if common_resolution:
451 for output in configuration:
452 configuration[output].options = {}
454 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
455 configuration[output].options["pos"] = "0x0"
457 configuration[output].options["off"] = None
458 elif profile_name in ("horizontal", "vertical"):
460 if profile_name == "horizontal":
461 shift_index = "width"
462 pos_specifier = "%sx0"
464 shift_index = "height"
465 pos_specifier = "0x%s"
467 for output in configuration:
468 configuration[output].options = {}
470 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
471 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
472 configuration[output].options["rate"] = mode["rate"]
473 configuration[output].options["pos"] = pos_specifier % shift
474 shift += int(mode[shift_index])
476 configuration[output].options["off"] = None
480 "Print help and exit"
482 for profile in virtual_profiles:
483 print(" %-10s %s" % profile[:2])
486 def exec_scripts(profile_path, script_name):
488 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
489 if os.access(script, os.X_OK | os.F_OK):
490 subprocess.call(script)
494 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
495 except getopt.GetoptError as e:
497 options = { "--help": True }
499 profile_path = os.path.expanduser("~/.autorandr")
502 profiles = load_profiles(profile_path)
504 if e.errno == 2: # No such file or directory
508 except Exception as e:
509 print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
513 config, modes = parse_xrandr_output()
514 except Exception as e:
515 print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
518 if "--fingerprint" in options:
519 output_setup(config, sys.stdout)
522 if "--config" in options:
523 output_configuration(config, sys.stdout)
527 options["--save"] = options["-s"]
528 if "--save" in options:
529 if options["--save"] in ( x[0] for x in virtual_profiles ):
530 print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
533 save_configuration(os.path.join(profile_path, options["--save"]), config)
534 except Exception as e:
535 print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
537 print("Saved current configuration as profile '%s'" % options["--save"])
540 if "-h" in options or "--help" in options:
543 detected_profile = find_profile(config, profiles)
547 options["--load"] = options["-l"]
548 if "--load" in options:
549 load_profile = options["--load"]
551 for profile_name in profiles.keys():
552 if profile_blocked(os.path.join(profile_path, profile_name)):
553 print("%s (blocked)" % profile_name)
555 if detected_profile == profile_name:
556 print("%s (detected)" % profile_name)
557 if "-c" in options or "--change" in options:
558 load_profile = detected_profile
563 options["--default"] = options["-d"]
564 if not load_profile and "--default" in options:
565 load_profile = options["--default"]
568 if load_profile in ( x[0] for x in virtual_profiles ):
569 profile = generate_virtual_profile(config, modes, load_profile)
572 profile = profiles[load_profile]
574 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
576 add_unused_outputs(config, profile)
577 if profile == config and not "-f" in options and not "--force" in options:
578 print("Config already loaded")
582 if "--dry-run" in options:
583 apply_configuration(profile, True)
585 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
586 apply_configuration(profile, False)
587 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
588 except Exception as e:
589 print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
594 if __name__ == '__main__':
597 except Exception as e:
598 print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)