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/>.
24 # TODO Add virtual profiles common, horizontal, vertical
25 # This also requires to load all resolutions into the XrandrOutputs
27 from __future__ import print_function
38 from itertools import chain
39 from collections import OrderedDict
42 # (name, description, callback)
43 ("common", "Clone all connected outputs at the largest common resolution", None),
44 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
45 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
49 Usage: autorandr [options]
51 -h, --help get this small help
52 -c, --change reload current setup
53 -s, --save <profile> save your current setup to profile <profile>
54 -l, --load <profile> load profile <profile>
55 -d, --default <profile> make profile <profile> the default profile
56 --force force (re)loading of a profile
57 --fingerprint fingerprint your current hardware setup
58 --config dump your current xrandr setup
59 --dry-run don't change anything, only print the xrandr commands
61 To prevent a profile from being loaded, place a script call "block" in its
62 directory. The script is evaluated before the screen setup is inspected, and
63 in case of it returning a value of 0 the profile is skipped. This can be used
64 to query the status of a docking station you are about to leave.
66 If no suitable profile can be identified, the current configuration is kept.
67 To change this behaviour and switch to a fallback configuration, specify
70 Another script called "postswitch "can be placed in the directory
71 ~/.autorandr as well as in any profile directories: The scripts are executed
72 after a mode switch has taken place and can notify window managers.
74 The following virtual configurations are available:
77 class XrandrOutput(object):
78 "Represents an XRandR output"
80 # This regular expression is used to parse an output in `xrandr --verbose'
81 XRANDR_OUTPUT_REGEXP = """(?x)
82 ^(?P<output>[^ ]+)\s+ # Line starts with output name
83 (?: # Differentiate disconnected and connected in first line
86 (?P<connected>connected)\s+ # If connected:
87 (?P<primary>primary\ )? # Might be primary screen
88 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution
89 \+(?P<x>[0-9]+)\+(?P<y>[0-9]+)\s+ # Position
90 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
91 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
92 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
94 (?:\s*(?: # Properties of the output
95 Gamma: (?P<gamma>[0-9\.:\s]+) | # Gamma value
96 Transform: (?P<transform>[0-9\.\s]+) | # Transformation matrix
97 EDID: (?P<edid>[0-9a-f\s]+) | # EDID of the output
98 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
102 [0-9]+x[0-9]+.+?\*current.+\s+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
108 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
109 (?P<width>[0-9]+)x(?P<height>[0-9]+)
110 .*?(?P<preferred>\+preferred)?
112 \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
116 return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
119 def option_vector(self):
120 "Return the command line parameters for XRandR for this instance"
121 return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), self.options.items())], [])
124 def option_string(self):
125 "Return the command line parameters in the configuration file format"
126 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
130 "Return a key to sort the outputs for xrandr invocation"
133 if "pos" in self.options:
134 x, y = map(float, self.options["pos"].split("x"))
139 def __init__(self, output, edid, options):
140 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
143 self.options = options
146 def from_xrandr_output(cls, xrandr_output):
147 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
149 This method also returns a list of modes supported by the output.
152 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
154 raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
156 raise RuntimeError("Parsing XRandR output failed, the regular expression did not match.")
157 remainder = xrandr_output[len(match_object.group(0)):]
159 raise RuntimeError("Parsing XRandR output failed, %d bytes left unmatched after regular expression." % len(remainder))
162 match = match_object.groupdict()
166 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
169 if not match["connected"]:
170 options["off"] = None
173 if match["rotate"] not in ("left", "right"):
174 options["mode"] = "%sx%s" % (match["width"], match["height"])
176 options["mode"] = "%sx%s" % (match["height"], match["width"])
177 options["rotate"] = match["rotate"]
178 options["reflect"] = "normal"
179 if "reflect" in match:
180 if match["reflect"] == "X":
181 options["reflect"] = "x"
182 elif match["reflect"] == "Y":
183 options["reflect"] = "y"
184 elif match["reflect"] == "X and Y":
185 options["reflect"] = "xy"
186 options["pos"] = "%sx%s" % (match["x"], match["y"])
187 if match["transform"]:
188 transformation = ",".join(match["transform"].strip().split())
189 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
190 options["transform"] = transformation
192 options["transform"] = "none"
194 gamma = match["gamma"].strip()
195 if gamma != "1.0:1.0:1.0":
196 options["gamma"] = gamma
198 options["rate"] = match["rate"]
199 edid = "".join(match["edid"].strip().split())
201 return XrandrOutput(match["output"], edid, options), modes
204 def from_config_file(cls, edid_map, configuration):
205 "Instanciate an XrandrOutput from the contents of a configuration file"
207 for line in configuration.split("\n"):
209 line = line.split(None, 1)
210 options[line[0]] = line[1] if len(line) > 1 else None
214 if options["output"] in edid_map:
215 edid = edid_map[options["output"]]
217 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
218 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
219 if fuzzy_output not in fuzzy_edid_map:
220 raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"])
221 edid = edid_map[edid_map.keys()[fuzzy_edid_map.index(fuzzy_output)]]
222 output = options["output"]
223 del options["output"]
225 return XrandrOutput(output, edid, options)
227 def edid_equals(self, other):
228 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
229 if self.edid and other.edid:
230 if len(self.edid) == 32 and len(other.edid) != 32:
231 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
232 if len(self.edid) != 32 and len(other.edid) == 32:
233 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
234 return self.edid == other.edid
236 def __eq__(self, other):
237 return self.edid == other.edid and self.output == other.output and self.options == other.options
239 def parse_xrandr_output():
240 "Parse the output of `xrandr --verbose' into a list of outputs"
241 xrandr_output = os.popen("xrandr -q --verbose").read()
242 if not xrandr_output:
243 raise RuntimeError("Failed to run xrandr")
245 # We are not interested in screens
246 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
248 # Split at output boundaries and instanciate an XrandrOutput per output
249 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
250 outputs = OrderedDict()
251 modes = OrderedDict()
252 for i in range(1, len(split_xrandr_output), 2):
253 output_name = split_xrandr_output[i].split()[0]
254 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
255 outputs[output_name] = output
257 modes[output_name] = output_modes
259 return outputs, modes
261 def load_profiles(profile_path):
262 "Load the stored profiles"
265 for profile in os.listdir(profile_path):
266 config_name = os.path.join(profile_path, profile, "config")
267 setup_name = os.path.join(profile_path, profile, "setup")
268 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
271 edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
275 for line in chain(open(config_name).readlines(), ["output"]):
276 if line[:6] == "output" and buffer:
277 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
282 profiles[profile] = config
286 def find_profile(current_config, profiles):
287 "Find a profile matching the currently connected outputs"
288 for profile_name, profile in profiles.items():
290 for name, output in profile.items():
293 if name not in current_config or not output.edid_equals(current_config[name]):
296 if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
301 def profile_blocked(profile_path):
302 "Check if a profile is blocked"
303 script = os.path.join(profile_path, "blocked")
304 if not os.access(script, os.X_OK | os.F_OK):
306 return subprocess.call(script) == 0
308 def output_configuration(configuration, config):
309 "Write a configuration file"
310 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
311 for output in outputs:
312 print(configuration[output].option_string, file=config)
314 def output_setup(configuration, setup):
315 "Write a setup (fingerprint) file"
316 outputs = sorted(configuration.keys())
317 for output in outputs:
318 if configuration[output].edid:
319 print(output, configuration[output].edid, file=setup)
321 def save_configuration(profile_path, configuration):
322 "Save a configuration into a profile"
323 if not os.path.isdir(profile_path):
324 os.makedirs(profile_path)
325 with open(os.path.join(profile_path, "config"), "w") as config:
326 output_configuration(configuration, config)
327 with open(os.path.join(profile_path, "setup"), "w") as setup:
328 output_setup(configuration, setup)
330 def apply_configuration(configuration, dry_run=False):
331 "Apply a configuration"
332 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
334 base_argv = [ "echo", "xrandr" ]
336 base_argv = [ "xrandr" ]
338 # Disable all unused outputs
340 for output in outputs:
341 if not configuration[output].edid:
342 argv += configuration[output].option_vector
343 if subprocess.call(argv) != 0:
346 # Enable remaining outputs in pairs of two
347 remaining_outputs = [ x for x in outputs if configuration[x].edid ]
348 for index in range(0, len(remaining_outputs), 2):
349 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:
352 def generate_virtual_profile(configuration, modes, profile_name):
353 "Generate one of the virtual profiles"
354 configuration = copy.deepcopy(configuration)
355 if profile_name == "common":
356 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
357 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
358 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
359 if common_resolution:
360 for output in configuration:
361 configuration[output].options = {}
363 configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
364 configuration[output].options["pos"] = "0x0"
366 configuration[output].options["off"] = None
367 elif profile_name in ("horizontal", "vertical"):
369 if profile_name == "horizontal":
370 shift_index = "width"
371 pos_specifier = "%sx0"
373 shift_index = "height"
374 pos_specifier = "0x%s"
376 for output in configuration:
377 configuration[output].options = {}
379 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
380 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
381 configuration[output].options["rate"] = mode["rate"]
382 configuration[output].options["pos"] = pos_specifier % shift
383 shift += int(mode[shift_index])
385 configuration[output].options["off"] = None
389 "Print help and exit"
391 for profile in virtual_profiles:
392 print(" %-10s %s" % profile[:2])
395 def exec_scripts(profile_path, script_name):
397 for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
398 if os.access(script, os.X_OK | os.F_OK):
399 subprocess.call(script)
402 options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
404 profile_path = os.path.expanduser("~/.autorandr")
405 profiles = load_profiles(profile_path)
406 config, modes = parse_xrandr_output()
408 if "--fingerprint" in options:
409 output_setup(config, sys.stdout)
412 if "--config" in options:
413 output_configuration(config, sys.stdout)
417 options["--save"] = options["-s"]
418 if "--save" in options:
419 if options["--save"] in ( x[0] for x in virtual_profiles ):
420 print("Cannot save current configuration as profile '%s': This configuration name is a reserved virtual configuration." % options["--save"])
422 save_configuration(os.path.join(profile_path, options["--save"]), config)
423 print("Saved current configuration as profile '%s'" % options["--save"])
426 if "-h" in options or "--help" in options:
429 detected_profile = find_profile(config, profiles)
433 options["--load"] = options["-l"]
434 if "--load" in options:
435 load_profile = options["--load"]
437 for profile_name in profiles.keys():
438 if profile_blocked(os.path.join(profile_path, profile_name)):
439 print("%s (blocked)" % profile_name)
441 if detected_profile == profile_name:
442 print("%s (detected)" % profile_name)
443 if "-c" in options or "--change" in options:
444 load_profile = detected_profile
449 options["--default"] = options["-d"]
450 if not load_profile and "--default" in options:
451 load_profile = options["--default"]
454 if load_profile in ( x[0] for x in virtual_profiles ):
455 profile = generate_virtual_profile(config, modes, load_profile)
457 profile = profiles[load_profile]
458 if profile == config and not "-f" in options and not "--force" in options:
459 print("Config already loaded")
462 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
463 apply_configuration(profile, "--dry-run" in options)
464 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
468 if __name__ == '__main__':