]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Python version: Python3 compatible error handling
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Experimental autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
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.
15 #
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.
20 #
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/>.
23 #
24
25 from __future__ import print_function
26 import copy
27 import getopt
28
29 import binascii
30 import hashlib
31 import os
32 import re
33 import subprocess
34 import sys
35
36 from itertools import chain
37 from collections import OrderedDict
38
39 virtual_profiles = [
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),
44 ]
45
46 help_text = """
47 Usage: autorandr [options]
48
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
58
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.
63
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
66  --default <profile>.
67
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.
71
72  The following virtual configurations are available:
73 """.strip()
74
75 class XrandrOutput(object):
76     "Represents an XRandR output"
77
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
82             disconnected |
83             unknown\ connection |
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
91         ).*
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
97         ))+
98         \s*
99         (?P<modes>(?:
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
102         )*)
103     """
104
105     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
106         (?P<width>[0-9]+)x(?P<height>[0-9]+)
107         .*?(?P<preferred>\+preferred)?
108         \s+h:.+
109         \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
110     """
111
112     def __repr__(self):
113         return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
114
115     @property
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())], [])
119
120     @property
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())])
124
125     @property
126     def sort_key(self):
127         "Return a key to sort the outputs for xrandr invocation"
128         if not self.edid:
129             return -1
130         if "pos" in self.options:
131             x, y = map(float, self.options["pos"].split("x"))
132         else:
133             x, y = 0, 0
134         return x + 10000 * y
135
136     def __init__(self, output, edid, options):
137         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
138         self.output = output
139         self.edid = edid
140         self.options = options
141
142     @classmethod
143     def from_xrandr_output(cls, xrandr_output):
144         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
145
146         This method also returns a list of modes supported by the output.
147         """
148         try:
149             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
150         except:
151             raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
152         if not match_object:
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)):]
156         if remainder:
157             raise RuntimeError(("Parsing XRandR output failed, %d bytes left unmatched after regular expression,"
158                                 "starting with ..'%s'.") % (len(remainder), remainder[:10]))
159
160
161         match = match_object.groupdict()
162
163         modes = []
164         if match["modes"]:
165             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
166             if not modes:
167                 raise RuntimeError("Parsing XRandR output failed, couldn't find any display modes")
168
169         options = {}
170         if not match["connected"]:
171             options["off"] = None
172             edid = None
173         else:
174             if match["rotate"] not in ("left", "right"):
175                 options["mode"] = "%sx%s" % (match["width"], match["height"])
176             else:
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
192                 else:
193                     options["transform"] = "none"
194             if match["gamma"]:
195                 gamma = match["gamma"].strip()
196                 if gamma != "1.0:1.0:1.0":
197                     options["gamma"] = gamma
198             if match["rate"]:
199                 options["rate"] = match["rate"]
200             edid = "".join(match["edid"].strip().split())
201
202         return XrandrOutput(match["output"], edid, options), modes
203
204     @classmethod
205     def from_config_file(cls, edid_map, configuration):
206         "Instanciate an XrandrOutput from the contents of a configuration file"
207         options = {}
208         for line in configuration.split("\n"):
209             if line:
210                 line = line.split(None, 1)
211                 options[line[0]] = line[1] if len(line) > 1 else None
212         if "off" in options:
213             edid = None
214         else:
215             if options["output"] in edid_map:
216                 edid = edid_map[options["output"]]
217             else:
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"]
225
226         return XrandrOutput(output, edid, options)
227
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
236
237     def __eq__(self, other):
238         return self.edid == other.edid and self.output == other.output and self.options == other.options
239
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"
242     try:
243         import regex
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]))
252     except ImportError:
253         pass
254     return "Debug information available if `regex' module is installed."
255
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")
261
262     # We are not interested in screens
263     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
264
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
273         if output_modes:
274             modes[output_name] = output_modes
275
276     return outputs, modes
277
278 def load_profiles(profile_path):
279     "Load the stored profiles"
280
281     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):
286             continue
287
288         edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
289
290         config = {}
291         buffer = []
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))
295                 buffer = [ line ]
296             else:
297                 buffer.append(line)
298
299         profiles[profile] = config
300
301     return profiles
302
303 def find_profile(current_config, profiles):
304     "Find a profile matching the currently connected outputs"
305     for profile_name, profile in profiles.items():
306         matches = True
307         for name, output in profile.items():
308             if not output.edid:
309                 continue
310             if name not in current_config or not output.edid_equals(current_config[name]):
311                 matches = False
312                 break
313         if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
314             continue
315         if matches:
316             return profile_name
317
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):
322         return False
323     return subprocess.call(script) == 0
324
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)
330
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)
337
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)
346
347 def apply_configuration(configuration, dry_run=False):
348     "Apply a configuration"
349     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
350     if dry_run:
351         base_argv = [ "echo", "xrandr" ]
352     else:
353         base_argv = [ "xrandr" ]
354
355     # Disable all unused outputs
356     argv = base_argv[:]
357     for output in outputs:
358         if not configuration[output].edid:
359             argv += configuration[output].option_vector
360     if subprocess.call(argv) != 0:
361         return False
362
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:
367             return False
368
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 = {}
379                 if output in modes:
380                     configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
381                     configuration[output].options["pos"] = "0x0"
382                 else:
383                     configuration[output].options["off"] = None
384     elif profile_name in ("horizontal", "vertical"):
385         shift = 0
386         if profile_name == "horizontal":
387             shift_index = "width"
388             pos_specifier = "%sx0"
389         else:
390             shift_index = "height"
391             pos_specifier = "0x%s"
392
393         for output in configuration:
394             configuration[output].options = {}
395             if output in modes:
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])
401             else:
402                 configuration[output].options["off"] = None
403     return configuration
404
405 def exit_help():
406     "Print help and exit"
407     print(help_text)
408     for profile in virtual_profiles:
409         print("  %-10s %s" % profile[:2])
410     sys.exit(0)
411
412 def exec_scripts(profile_path, script_name):
413     "Run userscripts"
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)
417
418 def main(argv):
419     options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
420
421     profile_path = os.path.expanduser("~/.autorandr")
422
423     try:
424         profiles = load_profiles(profile_path)
425     except Exception as e:
426         print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
427         sys.exit(1)
428
429     try:
430         config, modes = parse_xrandr_output()
431     except Exception as e:
432         print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
433         sys.exit(1)
434
435     if "--fingerprint" in options:
436         output_setup(config, sys.stdout)
437         sys.exit(0)
438
439     if "--config" in options:
440         output_configuration(config, sys.stdout)
441         sys.exit(0)
442
443     if "-s" in options:
444         options["--save"] = options["-s"]
445     if "--save" in options:
446         if options["--save"] in ( x[0] for x in virtual_profiles ):
447             print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
448             sys.exit(1)
449         try:
450             save_configuration(os.path.join(profile_path, options["--save"]), config)
451         except Exception as e:
452             print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
453             sys.exit(1)
454         print("Saved current configuration as profile '%s'" % options["--save"])
455         sys.exit(0)
456
457     if "-h" in options or "--help" in options:
458         exit_help()
459
460     detected_profile = find_profile(config, profiles)
461     load_profile = False
462
463     if "-l" in options:
464         options["--load"] = options["-l"]
465     if "--load" in options:
466         load_profile = options["--load"]
467     else:
468         for profile_name in profiles.keys():
469             if profile_blocked(os.path.join(profile_path, profile_name)):
470                 print("%s (blocked)" % profile_name)
471                 continue
472             if detected_profile == profile_name:
473                 print("%s (detected)" % profile_name)
474                 if "-c" in options or "--change" in options:
475                     load_profile = detected_profile
476             else:
477                 print(profile_name)
478
479     if "-d" in options:
480         options["--default"] = options["-d"]
481     if not load_profile and "--default" in options:
482         load_profile = options["--default"]
483
484     if load_profile:
485         if load_profile in ( x[0] for x in virtual_profiles ):
486             profile = generate_virtual_profile(config, modes, load_profile)
487         else:
488             profile = profiles[load_profile]
489         if profile == config and not "-f" in options and not "--force" in options:
490             print("Config already loaded")
491             sys.exit(0)
492
493         try:
494             if "--dry-run" in options:
495                 apply_configuration(profile, True)
496             else:
497                 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
498                 apply_configuration(profile, True)
499                 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
500         except Exception as e:
501             print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
502             sys.exit(1)
503
504     sys.exit(0)
505
506 if __name__ == '__main__':
507     try:
508         main(sys.argv)
509     except Exception as e:
510         print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)
511         sys.exit(1)