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
36 from itertools import chain
37 from collections import OrderedDict
40 # (name, description, callback)
41 ("common", "Clone all connected outputs at the largest common resolution", None),
42 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
43 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
47 Usage: autorandr [options]
49 -h, --help get this small help
50 -c, --change reload current setup
51 -s, --save <profile> save your current setup to profile <profile>
52 -l, --load <profile> load profile <profile>
53 -d, --default <profile> make profile <profile> the default profile
54 --force force (re)loading of a profile
55 --fingerprint fingerprint your current hardware setup
56 --config dump your current xrandr setup
57 --dry-run don't change anything, only print the xrandr commands
59 To prevent a profile from being loaded, place a script call "block" in its
60 directory. The script is evaluated before the screen setup is inspected, and
61 in case of it returning a value of 0 the profile is skipped. This can be used
62 to query the status of a docking station you are about to leave.
64 If no suitable profile can be identified, the current configuration is kept.
65 To change this behaviour and switch to a fallback configuration, specify
68 Another script called "postswitch "can be placed in the directory
69 ~/.autorandr as well as in any profile directories: The scripts are executed
70 after a mode switch has taken place and can notify window managers.
72 The following virtual configurations are available:
75 class XrandrOutput(object):
76 "Represents an XRandR output"
78 # This regular expression is used to parse an output in `xrandr --verbose'
79 XRANDR_OUTPUT_REGEXP = """(?x)
80 ^(?P<output>[^ ]+)\s+ # Line starts with output name
81 (?: # Differentiate disconnected and connected in first line
84 (?P<connected>connected)\s+ # If connected:
85 (?P<primary>primary\ )? # Might be primary screen
86 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution
87 \+(?P<x>[0-9]+)\+(?P<y>[0-9]+)\s+ # Position
88 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
89 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
90 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
92 (?:\s*(?: # Properties of the output
93 Gamma: (?P<gamma>[0-9\.:\s]+) | # Gamma value
94 Transform: (?P<transform>[0-9\.\s]+) | # Transformation matrix
95 EDID: (?P<edid>[0-9a-f\s]+) | # EDID of the output
96 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
100 [0-9]+x[0-9]+.+?\*current.+\s+h:.+\s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz\s* | # Interesting (current) resolution: Extract rate
101 [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s* # Other resolutions
105 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
106 (?P<width>[0-9]+)x(?P<height>[0-9]+)
107 .*?(?P<preferred>\+preferred)?
109 \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
113 return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
116 def option_vector(self):
117 "Return the command line parameters for XRandR for this instance"
118 return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), self.options.items())], [])
121 def option_string(self):
122 "Return the command line parameters in the configuration file format"
123 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
127 "Return a key to sort the outputs for xrandr invocation"
130 if "pos" in self.options:
131 x, y = map(float, self.options["pos"].split("x"))
136 def __init__(self, output, edid, options):
137 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
140 self.options = options
143 def from_xrandr_output(cls, xrandr_output):
144 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
146 This method also returns a list of modes supported by the output.
149 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
151 raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
153 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
154 raise RuntimeError("Parsing XRandR output failed, the regular expression did not match: %s" % debug)
155 remainder = xrandr_output[len(match_object.group(0)):]
157 raise RuntimeError(("Parsing XRandR output failed, %d bytes left unmatched after regular expression,"
158 "starting with ..'%s'.") % (len(remainder), remainder[:10]))
161 match = match_object.groupdict()
165 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
167 raise RuntimeError("Parsing XRandR output failed, couldn't find any display modes")
170 if not match["connected"]:
171 options["off"] = None
174 if match["rotate"] not in ("left", "right"):
175 options["mode"] = "%sx%s" % (match["width"], match["height"])
177 options["mode"] = "%sx%s" % (match["height"], match["width"])
178 options["rotate"] = match["rotate"]
179 options["reflect"] = "normal"
180 if "reflect" in match:
181 if match["reflect"] == "X":
182 options["reflect"] = "x"
183 elif match["reflect"] == "Y":
184 options["reflect"] = "y"
185 elif match["reflect"] == "X and Y":
186 options["reflect"] = "xy"
187 options["pos"] = "%sx%s" % (match["x"], match["y"])
188 if match["transform"]:
189 transformation = ",".join(match["transform"].strip().split())
190 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
191 options["transform"] = transformation
193 options["transform"] = "none"
195 gamma = match["gamma"].strip()
196 if gamma != "1.0:1.0:1.0":
197 options["gamma"] = gamma
199 options["rate"] = match["rate"]
200 edid = "".join(match["edid"].strip().split())
202 return XrandrOutput(match["output"], edid, options), modes
205 def from_config_file(cls, edid_map, configuration):
206 "Instanciate an XrandrOutput from the contents of a configuration file"
208 for line in configuration.split("\n"):
210 line = line.split(None, 1)
211 options[line[0]] = line[1] if len(line) > 1 else None
215 if options["output"] in edid_map:
216 edid = edid_map[options["output"]]
218 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
219 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
220 if fuzzy_output not in fuzzy_edid_map:
221 raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"])
222 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
223 output = options["output"]
224 del options["output"]
226 return XrandrOutput(output, edid, options)
228 def edid_equals(self, other):
229 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
230 if self.edid and other.edid:
231 if len(self.edid) == 32 and len(other.edid) != 32:
232 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
233 if len(self.edid) != 32 and len(other.edid) == 32:
234 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
235 return self.edid == other.edid
237 def __eq__(self, other):
238 return self.edid == other.edid and self.output == other.output and self.options == other.options
240 def debug_regexp(pattern, string):
241 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
244 bounds = ( 0, len(string) )
245 while bounds[0] != bounds[1]:
246 half = int((bounds[0] + bounds[1]) / 2)
247 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
248 partial_length = bounds[0]
249 return ("Regular expression matched until position "
250 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
251 string[partial_length:partial_length+10]))
254 return "Debug information available if `regex' module is installed."
256 def parse_xrandr_output():
257 "Parse the output of `xrandr --verbose' into a list of outputs"
258 xrandr_output = os.popen("xrandr -q --verbose").read()
259 if not xrandr_output:
260 raise RuntimeError("Failed to run xrandr")
262 # We are not interested in screens
263 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
265 # Split at output boundaries and instanciate an XrandrOutput per output
266 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
267 outputs = OrderedDict()
268 modes = OrderedDict()
269 for i in range(1, len(split_xrandr_output), 2):
270 output_name = split_xrandr_output[i].split()[0]
271 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
272 outputs[output_name] = output
274 modes[output_name] = output_modes
276 return outputs, modes
278 def load_profiles(profile_path):
279 "Load the stored profiles"
282 for profile in os.listdir(profile_path):
283 config_name = os.path.join(profile_path, profile, "config")
284 setup_name = os.path.join(profile_path, profile, "setup")
285 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
288 edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
292 for line in chain(open(config_name).readlines(), ["output"]):
293 if line[:6] == "output" and buffer:
294 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
299 profiles[profile] = config
303 def find_profile(current_config, profiles):
304 "Find a profile matching the currently connected outputs"
305 for profile_name, profile in profiles.items():
307 for name, output in profile.items():
310 if name not in current_config or not output.edid_equals(current_config[name]):
313 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
318 def profile_blocked(profile_path):
319 "Check if a profile is blocked"
320 script = os.path.join(profile_path, "blocked")
321 if not os.access(script, os.X_OK | os.F_OK):
323 return subprocess.call(script) == 0
325 def output_configuration(configuration, config):
326 "Write a configuration file"
327 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
328 for output in outputs:
329 print(configuration[output].option_string, file=config)
331 def output_setup(configuration, setup):
332 "Write a setup (fingerprint) file"
333 outputs = sorted(configuration.keys())
334 for output in outputs:
335 if configuration[output].edid:
336 print(output, configuration[output].edid, file=setup)
338 def save_configuration(profile_path, configuration):
339 "Save a configuration into a profile"
340 if not os.path.isdir(profile_path):
341 os.makedirs(profile_path)
342 with open(os.path.join(profile_path, "config"), "w") as config:
343 output_configuration(configuration, config)
344 with open(os.path.join(profile_path, "setup"), "w") as setup:
345 output_setup(configuration, setup)
347 def apply_configuration(configuration, dry_run=False):
348 "Apply a configuration"
349 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
351 base_argv = [ "echo", "xrandr" ]
353 base_argv = [ "xrandr" ]
355 # Disable all unused outputs
357 for output in outputs:
358 if not configuration[output].edid:
359 argv += configuration[output].option_vector
360 if subprocess.call(argv) != 0:
363 # Enable remaining outputs in pairs of two
364 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
365 for index in range(0, len(remaining_outputs), 2):
366 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:
369 def generate_virtual_profile(configuration, modes, profile_name):
370 "Generate one of the virtual profiles"
371 configuration = copy.deepcopy(configuration)
372 if profile_name == "common":
373 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
374 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
375 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
376 if common_resolution:
377 for output in configuration:
378 configuration[output].options = {}
380 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
381 configuration[output].options["pos"] = "0x0"
383 configuration[output].options["off"] = None
384 elif profile_name in ("horizontal", "vertical"):
386 if profile_name == "horizontal":
387 shift_index = "width"
388 pos_specifier = "%sx0"
390 shift_index = "height"
391 pos_specifier = "0x%s"
393 for output in configuration:
394 configuration[output].options = {}
396 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
397 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
398 configuration[output].options["rate"] = mode["rate"]
399 configuration[output].options["pos"] = pos_specifier % shift
400 shift += int(mode[shift_index])
402 configuration[output].options["off"] = None
406 "Print help and exit"
408 for profile in virtual_profiles:
409 print(" %-10s %s" % profile[:2])
412 def exec_scripts(profile_path, script_name):
414 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
415 if os.access(script, os.X_OK | os.F_OK):
416 subprocess.call(script)
420 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
421 except getopt.GetoptError as e:
423 options = { "--help": True }
425 profile_path = os.path.expanduser("~/.autorandr")
428 profiles = load_profiles(profile_path)
429 except Exception as e:
430 print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
434 config, modes = parse_xrandr_output()
435 except Exception as e:
436 print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
439 if "--fingerprint" in options:
440 output_setup(config, sys.stdout)
443 if "--config" in options:
444 output_configuration(config, sys.stdout)
448 options["--save"] = options["-s"]
449 if "--save" in options:
450 if options["--save"] in ( x[0] for x in virtual_profiles ):
451 print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
454 save_configuration(os.path.join(profile_path, options["--save"]), config)
455 except Exception as e:
456 print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
458 print("Saved current configuration as profile '%s'" % options["--save"])
461 if "-h" in options or "--help" in options:
464 detected_profile = find_profile(config, profiles)
468 options["--load"] = options["-l"]
469 if "--load" in options:
470 load_profile = options["--load"]
472 for profile_name in profiles.keys():
473 if profile_blocked(os.path.join(profile_path, profile_name)):
474 print("%s (blocked)" % profile_name)
476 if detected_profile == profile_name:
477 print("%s (detected)" % profile_name)
478 if "-c" in options or "--change" in options:
479 load_profile = detected_profile
484 options["--default"] = options["-d"]
485 if not load_profile and "--default" in options:
486 load_profile = options["--default"]
489 if load_profile in ( x[0] for x in virtual_profiles ):
490 profile = generate_virtual_profile(config, modes, load_profile)
492 profile = profiles[load_profile]
493 if profile == config and not "-f" in options and not "--force" in options:
494 print("Config already loaded")
498 if "--dry-run" in options:
499 apply_configuration(profile, True)
501 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
502 apply_configuration(profile, False)
503 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
504 except Exception as e:
505 print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
510 if __name__ == '__main__':
513 except Exception as e:
514 print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)