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 profiles[profile] = config
329 def find_profile(current_config, profiles):
330 "Find a profile matching the currently connected outputs"
331 for profile_name, profile in profiles.items():
333 for name, output in profile.items():
336 if name not in current_config or not output.edid_equals(current_config[name]):
339 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
344 def profile_blocked(profile_path):
345 "Check if a profile is blocked"
346 script = os.path.join(profile_path, "blocked")
347 if not os.access(script, os.X_OK | os.F_OK):
349 return subprocess.call(script) == 0
351 def output_configuration(configuration, config):
352 "Write a configuration file"
353 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
354 for output in outputs:
355 print(configuration[output].option_string, file=config)
357 def output_setup(configuration, setup):
358 "Write a setup (fingerprint) file"
359 outputs = sorted(configuration.keys())
360 for output in outputs:
361 if configuration[output].edid:
362 print(output, configuration[output].edid, file=setup)
364 def save_configuration(profile_path, configuration):
365 "Save a configuration into a profile"
366 if not os.path.isdir(profile_path):
367 os.makedirs(profile_path)
368 with open(os.path.join(profile_path, "config"), "w") as config:
369 output_configuration(configuration, config)
370 with open(os.path.join(profile_path, "setup"), "w") as setup:
371 output_setup(configuration, setup)
373 def apply_configuration(configuration, dry_run=False):
374 "Apply a configuration"
375 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
377 base_argv = [ "echo", "xrandr" ]
379 base_argv = [ "xrandr" ]
381 # Disable all unused outputs
383 for output in outputs:
384 if not configuration[output].edid:
385 argv += configuration[output].option_vector
386 if subprocess.call(argv) != 0:
389 # Enable remaining outputs in pairs of two
390 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
391 for index in range(0, len(remaining_outputs), 2):
392 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:
395 def generate_virtual_profile(configuration, modes, profile_name):
396 "Generate one of the virtual profiles"
397 configuration = copy.deepcopy(configuration)
398 if profile_name == "common":
399 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
400 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
401 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
402 if common_resolution:
403 for output in configuration:
404 configuration[output].options = {}
406 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
407 configuration[output].options["pos"] = "0x0"
409 configuration[output].options["off"] = None
410 elif profile_name in ("horizontal", "vertical"):
412 if profile_name == "horizontal":
413 shift_index = "width"
414 pos_specifier = "%sx0"
416 shift_index = "height"
417 pos_specifier = "0x%s"
419 for output in configuration:
420 configuration[output].options = {}
422 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
423 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
424 configuration[output].options["rate"] = mode["rate"]
425 configuration[output].options["pos"] = pos_specifier % shift
426 shift += int(mode[shift_index])
428 configuration[output].options["off"] = None
432 "Print help and exit"
434 for profile in virtual_profiles:
435 print(" %-10s %s" % profile[:2])
438 def exec_scripts(profile_path, script_name):
440 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
441 if os.access(script, os.X_OK | os.F_OK):
442 subprocess.call(script)
446 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
447 except getopt.GetoptError as e:
449 options = { "--help": True }
451 profile_path = os.path.expanduser("~/.autorandr")
454 profiles = load_profiles(profile_path)
455 except Exception as e:
456 print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
460 config, modes = parse_xrandr_output()
461 except Exception as e:
462 print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
465 if "--fingerprint" in options:
466 output_setup(config, sys.stdout)
469 if "--config" in options:
470 output_configuration(config, sys.stdout)
474 options["--save"] = options["-s"]
475 if "--save" in options:
476 if options["--save"] in ( x[0] for x in virtual_profiles ):
477 print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
480 save_configuration(os.path.join(profile_path, options["--save"]), config)
481 except Exception as e:
482 print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
484 print("Saved current configuration as profile '%s'" % options["--save"])
487 if "-h" in options or "--help" in options:
490 detected_profile = find_profile(config, profiles)
494 options["--load"] = options["-l"]
495 if "--load" in options:
496 load_profile = options["--load"]
498 for profile_name in profiles.keys():
499 if profile_blocked(os.path.join(profile_path, profile_name)):
500 print("%s (blocked)" % profile_name)
502 if detected_profile == profile_name:
503 print("%s (detected)" % profile_name)
504 if "-c" in options or "--change" in options:
505 load_profile = detected_profile
510 options["--default"] = options["-d"]
511 if not load_profile and "--default" in options:
512 load_profile = options["--default"]
515 if load_profile in ( x[0] for x in virtual_profiles ):
516 profile = generate_virtual_profile(config, modes, load_profile)
518 profile = profiles[load_profile]
519 if profile == config and not "-f" in options and not "--force" in options:
520 print("Config already loaded")
524 if "--dry-run" in options:
525 apply_configuration(profile, True)
527 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
528 apply_configuration(profile, False)
529 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
530 except Exception as e:
531 print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
536 if __name__ == '__main__':
539 except Exception as e:
540 print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)