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 if half == bounds[0]:
283 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
284 partial_length = bounds[0]
285 return ("Regular expression matched until position "
286 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
287 string[partial_length:partial_length+10]))
290 return "Debug information available if `regex' module is installed."
292 def parse_xrandr_output():
293 "Parse the output of `xrandr --verbose' into a list of outputs"
294 xrandr_output = os.popen("xrandr -q --verbose").read()
295 if not xrandr_output:
296 raise RuntimeError("Failed to run xrandr")
298 # We are not interested in screens
299 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
301 # Split at output boundaries and instanciate an XrandrOutput per output
302 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
303 outputs = OrderedDict()
304 modes = OrderedDict()
305 for i in range(1, len(split_xrandr_output), 2):
306 output_name = split_xrandr_output[i].split()[0]
307 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
308 outputs[output_name] = output
310 modes[output_name] = output_modes
312 return outputs, modes
314 def load_profiles(profile_path):
315 "Load the stored profiles"
318 for profile in os.listdir(profile_path):
319 config_name = os.path.join(profile_path, profile, "config")
320 setup_name = os.path.join(profile_path, profile, "setup")
321 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
324 edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
328 for line in chain(open(config_name).readlines(), ["output"]):
329 if line[:6] == "output" and buffer:
330 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
335 for output_name in list(config.keys()):
336 if "off" in config[output_name].options:
337 del config[output_name]
339 profiles[profile] = config
343 def find_profile(current_config, profiles):
344 "Find a profile matching the currently connected outputs"
345 for profile_name, profile in profiles.items():
347 for name, output in profile.items():
350 if name not in current_config or not output.edid_equals(current_config[name]):
353 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
358 def profile_blocked(profile_path):
359 "Check if a profile is blocked"
360 script = os.path.join(profile_path, "blocked")
361 if not os.access(script, os.X_OK | os.F_OK):
363 return subprocess.call(script) == 0
365 def output_configuration(configuration, config):
366 "Write a configuration file"
367 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
368 for output in outputs:
369 print(configuration[output].option_string, file=config)
371 def output_setup(configuration, setup):
372 "Write a setup (fingerprint) file"
373 outputs = sorted(configuration.keys())
374 for output in outputs:
375 if configuration[output].edid:
376 print(output, configuration[output].edid, file=setup)
378 def save_configuration(profile_path, configuration):
379 "Save a configuration into a profile"
380 if not os.path.isdir(profile_path):
381 os.makedirs(profile_path)
382 with open(os.path.join(profile_path, "config"), "w") as config:
383 output_configuration(configuration, config)
384 with open(os.path.join(profile_path, "setup"), "w") as setup:
385 output_setup(configuration, setup)
387 def apply_configuration(configuration, dry_run=False):
388 "Apply a configuration"
389 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
391 base_argv = [ "echo", "xrandr" ]
393 base_argv = [ "xrandr" ]
395 # Disable all unused outputs
397 for output in outputs:
398 if not configuration[output].edid:
399 argv += configuration[output].option_vector
400 if argv != base_argv:
401 if subprocess.call(argv) != 0:
404 # Enable remaining outputs in pairs of two
405 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
406 for index in range(0, len(remaining_outputs), 2):
407 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:
410 def add_unused_outputs(source_configuration, target_configuration):
411 "Add outputs that are missing in target to target, in 'off' state"
412 for output_name, output in source_configuration.items():
413 if output_name not in target_configuration:
414 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
416 def generate_virtual_profile(configuration, modes, profile_name):
417 "Generate one of the virtual profiles"
418 configuration = copy.deepcopy(configuration)
419 if profile_name == "common":
420 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
421 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
422 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
423 if common_resolution:
424 for output in configuration:
425 configuration[output].options = {}
427 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
428 configuration[output].options["pos"] = "0x0"
430 configuration[output].options["off"] = None
431 elif profile_name in ("horizontal", "vertical"):
433 if profile_name == "horizontal":
434 shift_index = "width"
435 pos_specifier = "%sx0"
437 shift_index = "height"
438 pos_specifier = "0x%s"
440 for output in configuration:
441 configuration[output].options = {}
443 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
444 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
445 configuration[output].options["rate"] = mode["rate"]
446 configuration[output].options["pos"] = pos_specifier % shift
447 shift += int(mode[shift_index])
449 configuration[output].options["off"] = None
453 "Print help and exit"
455 for profile in virtual_profiles:
456 print(" %-10s %s" % profile[:2])
459 def exec_scripts(profile_path, script_name):
461 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
462 if os.access(script, os.X_OK | os.F_OK):
463 subprocess.call(script)
467 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
468 except getopt.GetoptError as e:
470 options = { "--help": True }
472 profile_path = os.path.expanduser("~/.autorandr")
475 profiles = load_profiles(profile_path)
476 except Exception as e:
477 print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
481 config, modes = parse_xrandr_output()
482 except Exception as e:
483 print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
486 if "--fingerprint" in options:
487 output_setup(config, sys.stdout)
490 if "--config" in options:
491 output_configuration(config, sys.stdout)
495 options["--save"] = options["-s"]
496 if "--save" in options:
497 if options["--save"] in ( x[0] for x in virtual_profiles ):
498 print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
501 save_configuration(os.path.join(profile_path, options["--save"]), config)
502 except Exception as e:
503 print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
505 print("Saved current configuration as profile '%s'" % options["--save"])
508 if "-h" in options or "--help" in options:
511 detected_profile = find_profile(config, profiles)
515 options["--load"] = options["-l"]
516 if "--load" in options:
517 load_profile = options["--load"]
519 for profile_name in profiles.keys():
520 if profile_blocked(os.path.join(profile_path, profile_name)):
521 print("%s (blocked)" % profile_name)
523 if detected_profile == profile_name:
524 print("%s (detected)" % profile_name)
525 if "-c" in options or "--change" in options:
526 load_profile = detected_profile
531 options["--default"] = options["-d"]
532 if not load_profile and "--default" in options:
533 load_profile = options["--default"]
536 if load_profile in ( x[0] for x in virtual_profiles ):
537 profile = generate_virtual_profile(config, modes, load_profile)
540 profile = profiles[load_profile]
542 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
544 add_unused_outputs(config, profile)
545 if profile == config and not "-f" in options and not "--force" in options:
546 print("Config already loaded")
550 if "--dry-run" in options:
551 apply_configuration(profile, True)
553 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
554 apply_configuration(profile, False)
555 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
556 except Exception as e:
557 print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
562 if __name__ == '__main__':
565 except Exception as e:
566 print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)