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:
86 (?P<primary>primary\ )? # Might be primary screen
87 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution
88 \+(?P<x>[0-9]+)\+(?P<y>[0-9]+)\s+ # Position
89 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
90 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
91 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
93 (?:\s*(?: # Properties of the output
94 Gamma: (?P<gamma>[0-9\.:\s]+) | # Gamma value
95 Transform: (?P<transform>[0-9\.\s]+) | # Transformation matrix
96 EDID: (?P<edid>[0-9a-f\s]+) | # EDID of the output
97 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
101 [0-9]+x[0-9]+.+?\*current.+\s+h:.+\s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz\s* | # Interesting (current) resolution: Extract rate
102 [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s* # Other resolutions
106 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
107 (?P<width>[0-9]+)x(?P<height>[0-9]+)
108 .*?(?P<preferred>\+preferred)?
110 \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
114 return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
117 def options_with_defaults(self):
118 "Return the options dictionary, augmented with the default values that weren't set"
119 if "off" in self.options:
122 if xrandr_version() >= Version("1.3"):
124 "transform": "1,0,0,0,1,0,0,0,1",
126 if xrandr_version() >= Version("1.2"):
132 options.update(self.options)
136 def option_vector(self):
137 "Return the command line parameters for XRandR for this instance"
138 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())], [])
141 def option_string(self):
142 "Return the command line parameters in the configuration file format"
143 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
147 "Return a key to sort the outputs for xrandr invocation"
150 if "pos" in self.options:
151 x, y = map(float, self.options["pos"].split("x"))
156 def __init__(self, output, edid, options):
157 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
160 self.options = options
163 def from_xrandr_output(cls, xrandr_output):
164 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
166 This method also returns a list of modes supported by the output.
169 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
171 raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
173 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
174 raise RuntimeError("Parsing XRandR output failed, the regular expression did not match: %s" % debug)
175 remainder = xrandr_output[len(match_object.group(0)):]
177 raise RuntimeError(("Parsing XRandR output failed, %d bytes left unmatched after regular expression,"
178 "starting with ..'%s'.") % (len(remainder), remainder[:10]))
181 match = match_object.groupdict()
185 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
187 raise RuntimeError("Parsing XRandR output failed, couldn't find any display modes")
190 if not match["connected"]:
191 options["off"] = None
194 if match["rotate"] not in ("left", "right"):
195 options["mode"] = "%sx%s" % (match["width"], match["height"])
197 options["mode"] = "%sx%s" % (match["height"], match["width"])
198 if match["rotate"] != "normal":
199 options["rotate"] = match["rotate"]
200 if "reflect" in match:
201 if match["reflect"] == "X":
202 options["reflect"] = "x"
203 elif match["reflect"] == "Y":
204 options["reflect"] = "y"
205 elif match["reflect"] == "X and Y":
206 options["reflect"] = "xy"
207 options["pos"] = "%sx%s" % (match["x"], match["y"])
208 if match["transform"]:
209 transformation = ",".join(match["transform"].strip().split())
210 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
211 options["transform"] = transformation
213 gamma = match["gamma"].strip()
214 if gamma != "1.0:1.0:1.0":
215 options["gamma"] = gamma
217 options["rate"] = match["rate"]
218 edid = "".join(match["edid"].strip().split())
220 return XrandrOutput(match["output"], edid, options), modes
223 def from_config_file(cls, edid_map, configuration):
224 "Instanciate an XrandrOutput from the contents of a configuration file"
226 for line in configuration.split("\n"):
228 line = line.split(None, 1)
229 options[line[0]] = line[1] if len(line) > 1 else None
233 if options["output"] in edid_map:
234 edid = edid_map[options["output"]]
236 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
237 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
238 if fuzzy_output not in fuzzy_edid_map:
239 raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"])
240 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
241 output = options["output"]
242 del options["output"]
244 return XrandrOutput(output, edid, options)
246 def edid_equals(self, other):
247 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
248 if self.edid and other.edid:
249 if len(self.edid) == 32 and len(other.edid) != 32:
250 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
251 if len(self.edid) != 32 and len(other.edid) == 32:
252 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
253 return self.edid == other.edid
255 def __eq__(self, other):
256 return self.edid == other.edid and self.output == other.output and self.options == other.options
258 def xrandr_version():
259 "Return the version of XRandR that this system uses"
260 if getattr(xrandr_version, "version", False) is False:
261 version_string = os.popen("xrandr -v").read()
262 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
263 xrandr_version.version = Version(version)
264 return xrandr_version.version
266 def debug_regexp(pattern, string):
267 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
270 bounds = ( 0, len(string) )
271 while bounds[0] != bounds[1]:
272 half = int((bounds[0] + bounds[1]) / 2)
273 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
274 partial_length = bounds[0]
275 return ("Regular expression matched until position "
276 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
277 string[partial_length:partial_length+10]))
280 return "Debug information available if `regex' module is installed."
282 def parse_xrandr_output():
283 "Parse the output of `xrandr --verbose' into a list of outputs"
284 xrandr_output = os.popen("xrandr -q --verbose").read()
285 if not xrandr_output:
286 raise RuntimeError("Failed to run xrandr")
288 # We are not interested in screens
289 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
291 # Split at output boundaries and instanciate an XrandrOutput per output
292 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
293 outputs = OrderedDict()
294 modes = OrderedDict()
295 for i in range(1, len(split_xrandr_output), 2):
296 output_name = split_xrandr_output[i].split()[0]
297 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
298 outputs[output_name] = output
300 modes[output_name] = output_modes
302 return outputs, modes
304 def load_profiles(profile_path):
305 "Load the stored profiles"
308 for profile in os.listdir(profile_path):
309 config_name = os.path.join(profile_path, profile, "config")
310 setup_name = os.path.join(profile_path, profile, "setup")
311 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
314 edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
318 for line in chain(open(config_name).readlines(), ["output"]):
319 if line[:6] == "output" and buffer:
320 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
325 for output_name in list(config.keys()):
326 if "off" in config[output_name].options:
327 del config[output_name]
329 profiles[profile] = config
333 def find_profile(current_config, profiles):
334 "Find a profile matching the currently connected outputs"
335 for profile_name, profile in profiles.items():
337 for name, output in profile.items():
340 if name not in current_config or not output.edid_equals(current_config[name]):
343 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
348 def profile_blocked(profile_path):
349 "Check if a profile is blocked"
350 script = os.path.join(profile_path, "blocked")
351 if not os.access(script, os.X_OK | os.F_OK):
353 return subprocess.call(script) == 0
355 def output_configuration(configuration, config):
356 "Write a configuration file"
357 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
358 for output in outputs:
359 print(configuration[output].option_string, file=config)
361 def output_setup(configuration, setup):
362 "Write a setup (fingerprint) file"
363 outputs = sorted(configuration.keys())
364 for output in outputs:
365 if configuration[output].edid:
366 print(output, configuration[output].edid, file=setup)
368 def save_configuration(profile_path, configuration):
369 "Save a configuration into a profile"
370 if not os.path.isdir(profile_path):
371 os.makedirs(profile_path)
372 with open(os.path.join(profile_path, "config"), "w") as config:
373 output_configuration(configuration, config)
374 with open(os.path.join(profile_path, "setup"), "w") as setup:
375 output_setup(configuration, setup)
377 def apply_configuration(configuration, dry_run=False):
378 "Apply a configuration"
379 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
381 base_argv = [ "echo", "xrandr" ]
383 base_argv = [ "xrandr" ]
385 # Disable all unused outputs
387 for output in outputs:
388 if not configuration[output].edid:
389 argv += configuration[output].option_vector
390 if argv != base_argv:
391 if subprocess.call(argv) != 0:
394 # Enable remaining outputs in pairs of two
395 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
396 for index in range(0, len(remaining_outputs), 2):
397 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:
400 def add_unused_outputs(source_configuration, target_configuration):
401 "Add outputs that are missing in target to target, in 'off' state"
402 for output_name, output in source_configuration.items():
403 if output_name not in target_configuration:
404 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
406 def generate_virtual_profile(configuration, modes, profile_name):
407 "Generate one of the virtual profiles"
408 configuration = copy.deepcopy(configuration)
409 if profile_name == "common":
410 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
411 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
412 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
413 if common_resolution:
414 for output in configuration:
415 configuration[output].options = {}
417 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
418 configuration[output].options["pos"] = "0x0"
420 configuration[output].options["off"] = None
421 elif profile_name in ("horizontal", "vertical"):
423 if profile_name == "horizontal":
424 shift_index = "width"
425 pos_specifier = "%sx0"
427 shift_index = "height"
428 pos_specifier = "0x%s"
430 for output in configuration:
431 configuration[output].options = {}
433 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
434 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
435 configuration[output].options["rate"] = mode["rate"]
436 configuration[output].options["pos"] = pos_specifier % shift
437 shift += int(mode[shift_index])
439 configuration[output].options["off"] = None
443 "Print help and exit"
445 for profile in virtual_profiles:
446 print(" %-10s %s" % profile[:2])
449 def exec_scripts(profile_path, script_name):
451 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
452 if os.access(script, os.X_OK | os.F_OK):
453 subprocess.call(script)
457 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
458 except getopt.GetoptError as e:
460 options = { "--help": True }
462 profile_path = os.path.expanduser("~/.autorandr")
465 profiles = load_profiles(profile_path)
466 except Exception as e:
467 print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
471 config, modes = parse_xrandr_output()
472 except Exception as e:
473 print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
476 if "--fingerprint" in options:
477 output_setup(config, sys.stdout)
480 if "--config" in options:
481 output_configuration(config, sys.stdout)
485 options["--save"] = options["-s"]
486 if "--save" in options:
487 if options["--save"] in ( x[0] for x in virtual_profiles ):
488 print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
491 save_configuration(os.path.join(profile_path, options["--save"]), config)
492 except Exception as e:
493 print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
495 print("Saved current configuration as profile '%s'" % options["--save"])
498 if "-h" in options or "--help" in options:
501 detected_profile = find_profile(config, profiles)
505 options["--load"] = options["-l"]
506 if "--load" in options:
507 load_profile = options["--load"]
509 for profile_name in profiles.keys():
510 if profile_blocked(os.path.join(profile_path, profile_name)):
511 print("%s (blocked)" % profile_name)
513 if detected_profile == profile_name:
514 print("%s (detected)" % profile_name)
515 if "-c" in options or "--change" in options:
516 load_profile = detected_profile
521 options["--default"] = options["-d"]
522 if not load_profile and "--default" in options:
523 load_profile = options["--default"]
526 if load_profile in ( x[0] for x in virtual_profiles ):
527 profile = generate_virtual_profile(config, modes, load_profile)
530 profile = profiles[load_profile]
532 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
534 add_unused_outputs(config, profile)
535 if profile == config and not "-f" in options and not "--force" in options:
536 print("Config already loaded")
540 if "--dry-run" in options:
541 apply_configuration(profile, True)
543 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
544 apply_configuration(profile, False)
545 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
546 except Exception as e:
547 print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
552 if __name__ == '__main__':
555 except Exception as e:
556 print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)