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 (might be overridden below!)
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 (?P<mode_width>[0-9]+)x(?P<mode_height>[0-9]+).+?\*current.+\s+
102 h:.+\s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz\s* | # Interesting (current) resolution: Extract rate
103 [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s* # Other resolutions
107 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
108 (?P<width>[0-9]+)x(?P<height>[0-9]+)
109 .*?(?P<preferred>\+preferred)?
111 \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
115 return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
118 def options_with_defaults(self):
119 "Return the options dictionary, augmented with the default values that weren't set"
120 if "off" in self.options:
123 if xrandr_version() >= Version("1.3"):
125 "transform": "1,0,0,0,1,0,0,0,1",
127 if xrandr_version() >= Version("1.2"):
133 options.update(self.options)
137 def option_vector(self):
138 "Return the command line parameters for XRandR for this instance"
139 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())], [])
142 def option_string(self):
143 "Return the command line parameters in the configuration file format"
144 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
148 "Return a key to sort the outputs for xrandr invocation"
151 if "pos" in self.options:
152 x, y = map(float, self.options["pos"].split("x"))
157 def __init__(self, output, edid, options):
158 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
161 self.options = options
164 def from_xrandr_output(cls, xrandr_output):
165 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
167 This method also returns a list of modes supported by the output.
170 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
172 raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
174 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
175 raise RuntimeError("Parsing XRandR output failed, the regular expression did not match: %s" % debug)
176 remainder = xrandr_output[len(match_object.group(0)):]
178 raise RuntimeError(("Parsing XRandR output failed, %d bytes left unmatched after regular expression,"
179 "starting with ..'%s'.") % (len(remainder), remainder[:10]))
182 match = match_object.groupdict()
186 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
188 raise RuntimeError("Parsing XRandR output failed, couldn't find any display modes")
191 if not match["connected"]:
192 options["off"] = None
195 if "mode_width" in match:
196 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
198 if match["rotate"] not in ("left", "right"):
199 options["mode"] = "%sx%s" % (match["width"], match["height"])
201 options["mode"] = "%sx%s" % (match["height"], match["width"])
202 if match["rotate"] != "normal":
203 options["rotate"] = match["rotate"]
204 if "reflect" in match:
205 if match["reflect"] == "X":
206 options["reflect"] = "x"
207 elif match["reflect"] == "Y":
208 options["reflect"] = "y"
209 elif match["reflect"] == "X and Y":
210 options["reflect"] = "xy"
211 options["pos"] = "%sx%s" % (match["x"], match["y"])
212 if match["transform"]:
213 transformation = ",".join(match["transform"].strip().split())
214 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
215 options["transform"] = transformation
216 if "mode_width" not in match:
217 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
218 # special case is actually required.
219 print("Warning: Output %s has a transformation applied. Could not determine correct mode!", file=sys.stderr)
221 gamma = match["gamma"].strip()
222 if gamma != "1.0:1.0:1.0":
223 options["gamma"] = gamma
225 options["rate"] = match["rate"]
226 edid = "".join(match["edid"].strip().split())
228 return XrandrOutput(match["output"], edid, options), modes
231 def from_config_file(cls, edid_map, configuration):
232 "Instanciate an XrandrOutput from the contents of a configuration file"
234 for line in configuration.split("\n"):
236 line = line.split(None, 1)
237 options[line[0]] = line[1] if len(line) > 1 else None
241 if options["output"] in edid_map:
242 edid = edid_map[options["output"]]
244 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
245 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
246 if fuzzy_output not in fuzzy_edid_map:
247 raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"])
248 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
249 output = options["output"]
250 del options["output"]
252 return XrandrOutput(output, edid, options)
254 def edid_equals(self, other):
255 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
256 if self.edid and other.edid:
257 if len(self.edid) == 32 and len(other.edid) != 32:
258 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
259 if len(self.edid) != 32 and len(other.edid) == 32:
260 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
261 return self.edid == other.edid
263 def __eq__(self, other):
264 return self.edid == other.edid and self.output == other.output and self.options == other.options
266 def xrandr_version():
267 "Return the version of XRandR that this system uses"
268 if getattr(xrandr_version, "version", False) is False:
269 version_string = os.popen("xrandr -v").read()
270 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
271 xrandr_version.version = Version(version)
272 return xrandr_version.version
274 def debug_regexp(pattern, string):
275 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
278 bounds = ( 0, len(string) )
279 while bounds[0] != bounds[1]:
280 half = int((bounds[0] + bounds[1]) / 2)
281 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
282 partial_length = bounds[0]
283 return ("Regular expression matched until position "
284 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
285 string[partial_length:partial_length+10]))
288 return "Debug information available if `regex' module is installed."
290 def parse_xrandr_output():
291 "Parse the output of `xrandr --verbose' into a list of outputs"
292 xrandr_output = os.popen("xrandr -q --verbose").read()
293 if not xrandr_output:
294 raise RuntimeError("Failed to run xrandr")
296 # We are not interested in screens
297 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
299 # Split at output boundaries and instanciate an XrandrOutput per output
300 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
301 outputs = OrderedDict()
302 modes = OrderedDict()
303 for i in range(1, len(split_xrandr_output), 2):
304 output_name = split_xrandr_output[i].split()[0]
305 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
306 outputs[output_name] = output
308 modes[output_name] = output_modes
310 return outputs, modes
312 def load_profiles(profile_path):
313 "Load the stored profiles"
316 for profile in os.listdir(profile_path):
317 config_name = os.path.join(profile_path, profile, "config")
318 setup_name = os.path.join(profile_path, profile, "setup")
319 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
322 edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
326 for line in chain(open(config_name).readlines(), ["output"]):
327 if line[:6] == "output" and buffer:
328 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
333 for output_name in list(config.keys()):
334 if "off" in config[output_name].options:
335 del config[output_name]
337 profiles[profile] = config
341 def find_profile(current_config, profiles):
342 "Find a profile matching the currently connected outputs"
343 for profile_name, profile in profiles.items():
345 for name, output in profile.items():
348 if name not in current_config or not output.edid_equals(current_config[name]):
351 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
356 def profile_blocked(profile_path):
357 "Check if a profile is blocked"
358 script = os.path.join(profile_path, "blocked")
359 if not os.access(script, os.X_OK | os.F_OK):
361 return subprocess.call(script) == 0
363 def output_configuration(configuration, config):
364 "Write a configuration file"
365 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
366 for output in outputs:
367 print(configuration[output].option_string, file=config)
369 def output_setup(configuration, setup):
370 "Write a setup (fingerprint) file"
371 outputs = sorted(configuration.keys())
372 for output in outputs:
373 if configuration[output].edid:
374 print(output, configuration[output].edid, file=setup)
376 def save_configuration(profile_path, configuration):
377 "Save a configuration into a profile"
378 if not os.path.isdir(profile_path):
379 os.makedirs(profile_path)
380 with open(os.path.join(profile_path, "config"), "w") as config:
381 output_configuration(configuration, config)
382 with open(os.path.join(profile_path, "setup"), "w") as setup:
383 output_setup(configuration, setup)
385 def apply_configuration(configuration, dry_run=False):
386 "Apply a configuration"
387 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
389 base_argv = [ "echo", "xrandr" ]
391 base_argv = [ "xrandr" ]
393 # Disable all unused outputs
395 for output in outputs:
396 if not configuration[output].edid:
397 argv += configuration[output].option_vector
398 if argv != base_argv:
399 if subprocess.call(argv) != 0:
402 # Enable remaining outputs in pairs of two
403 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
404 for index in range(0, len(remaining_outputs), 2):
405 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:
408 def add_unused_outputs(source_configuration, target_configuration):
409 "Add outputs that are missing in target to target, in 'off' state"
410 for output_name, output in source_configuration.items():
411 if output_name not in target_configuration:
412 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
414 def generate_virtual_profile(configuration, modes, profile_name):
415 "Generate one of the virtual profiles"
416 configuration = copy.deepcopy(configuration)
417 if profile_name == "common":
418 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
419 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
420 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
421 if common_resolution:
422 for output in configuration:
423 configuration[output].options = {}
425 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
426 configuration[output].options["pos"] = "0x0"
428 configuration[output].options["off"] = None
429 elif profile_name in ("horizontal", "vertical"):
431 if profile_name == "horizontal":
432 shift_index = "width"
433 pos_specifier = "%sx0"
435 shift_index = "height"
436 pos_specifier = "0x%s"
438 for output in configuration:
439 configuration[output].options = {}
441 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
442 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
443 configuration[output].options["rate"] = mode["rate"]
444 configuration[output].options["pos"] = pos_specifier % shift
445 shift += int(mode[shift_index])
447 configuration[output].options["off"] = None
451 "Print help and exit"
453 for profile in virtual_profiles:
454 print(" %-10s %s" % profile[:2])
457 def exec_scripts(profile_path, script_name):
459 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
460 if os.access(script, os.X_OK | os.F_OK):
461 subprocess.call(script)
465 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
466 except getopt.GetoptError as e:
468 options = { "--help": True }
470 profile_path = os.path.expanduser("~/.autorandr")
473 profiles = load_profiles(profile_path)
474 except Exception as e:
475 print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
479 config, modes = parse_xrandr_output()
480 except Exception as e:
481 print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
484 if "--fingerprint" in options:
485 output_setup(config, sys.stdout)
488 if "--config" in options:
489 output_configuration(config, sys.stdout)
493 options["--save"] = options["-s"]
494 if "--save" in options:
495 if options["--save"] in ( x[0] for x in virtual_profiles ):
496 print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
499 save_configuration(os.path.join(profile_path, options["--save"]), config)
500 except Exception as e:
501 print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
503 print("Saved current configuration as profile '%s'" % options["--save"])
506 if "-h" in options or "--help" in options:
509 detected_profile = find_profile(config, profiles)
513 options["--load"] = options["-l"]
514 if "--load" in options:
515 load_profile = options["--load"]
517 for profile_name in profiles.keys():
518 if profile_blocked(os.path.join(profile_path, profile_name)):
519 print("%s (blocked)" % profile_name)
521 if detected_profile == profile_name:
522 print("%s (detected)" % profile_name)
523 if "-c" in options or "--change" in options:
524 load_profile = detected_profile
529 options["--default"] = options["-d"]
530 if not load_profile and "--default" in options:
531 load_profile = options["--default"]
534 if load_profile in ( x[0] for x in virtual_profiles ):
535 profile = generate_virtual_profile(config, modes, load_profile)
538 profile = profiles[load_profile]
540 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
542 add_unused_outputs(config, profile)
543 if profile == config and not "-f" in options and not "--force" in options:
544 print("Config already loaded")
548 if "--dry-run" in options:
549 apply_configuration(profile, True)
551 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
552 apply_configuration(profile, False)
553 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
554 except Exception as e:
555 print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
560 if __name__ == '__main__':
563 except Exception as e:
564 print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)