]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
b2bca6976143c3c646bf3987349fd977531a81cb
[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 from distutils.version import LooseVersion as Version
36
37 from itertools import chain
38 from collections import OrderedDict
39
40 virtual_profiles = [
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),
45 ]
46
47 help_text = """
48 Usage: autorandr [options]
49
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
59
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.
64
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
67  --default <profile>.
68
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.
72
73  The following virtual configurations are available:
74 """.strip()
75
76 class XrandrOutput(object):
77     "Represents an XRandR output"
78
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
83             disconnected |
84             unknown\ connection |
85             (?P<connected>connected)\s+                                                 # If connected:
86             (?:
87             (?P<primary>primary\ )?                                                     # Might be primary screen
88             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
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
93             )?                                                                          # .. but everything of the above only if the screen is in use.
94         ).*
95         (?:\s*(?:                                                                       # Properties of the output
96             Gamma: (?P<gamma>[0-9\.:\s]+) |                                             # Gamma value
97             Transform: (?P<transform>[0-9\.\s]+) |                                      # Transformation matrix
98             EDID: (?P<edid>[0-9a-f\s]+) |                                               # EDID of the output
99             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
100         ))+
101         \s*
102         (?P<modes>(?:
103             (?P<mode_width>[0-9]+)x(?P<mode_height>[0-9]+).+?\*current.+\s+
104                 h:.+\s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz\s* |                            # Interesting (current) resolution: Extract rate
105             [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s*                                            # Other resolutions
106         )*)
107     """
108
109     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
110         (?P<width>[0-9]+)x(?P<height>[0-9]+)
111         .*?(?P<preferred>\+preferred)?
112         \s+h:.+
113         \s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz
114     """
115
116     def __repr__(self):
117         return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
118
119     @property
120     def options_with_defaults(self):
121         "Return the options dictionary, augmented with the default values that weren't set"
122         if "off" in self.options:
123             return self.options
124         options = {}
125         if xrandr_version() >= Version("1.3"):
126             options.update({
127                 "transform": "1,0,0,0,1,0,0,0,1",
128             })
129         if xrandr_version() >= Version("1.2"):
130             options.update({
131                 "reflect": "normal",
132                 "rotate": "normal",
133                 "gamma": "1:1:1",
134             })
135         options.update(self.options)
136         return options
137
138     @property
139     def option_vector(self):
140         "Return the command line parameters for XRandR for this instance"
141         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
143     @property
144     def option_string(self):
145         "Return the command line parameters in the configuration file format"
146         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
147
148     @property
149     def sort_key(self):
150         "Return a key to sort the outputs for xrandr invocation"
151         if not self.edid:
152             return -1
153         if "pos" in self.options:
154             x, y = map(float, self.options["pos"].split("x"))
155         else:
156             x, y = 0, 0
157         return x + 10000 * y
158
159     def __init__(self, output, edid, options):
160         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
161         self.output = output
162         self.edid = edid
163         self.options = options
164
165     @classmethod
166     def from_xrandr_output(cls, xrandr_output):
167         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
168
169         This method also returns a list of modes supported by the output.
170         """
171         try:
172             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
173         except:
174             raise RuntimeError("Parsing XRandR output failed, there is an error in the regular expression.")
175         if not match_object:
176             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
177             raise RuntimeError("Parsing XRandR output failed, the regular expression did not match: %s" % debug)
178         remainder = xrandr_output[len(match_object.group(0)):]
179         if remainder:
180             raise RuntimeError(("Parsing XRandR output failed, %d bytes left unmatched after regular expression,"
181                                 "starting with ..'%s'.") % (len(remainder), remainder[:10]))
182
183
184         match = match_object.groupdict()
185
186         modes = []
187         if match["modes"]:
188             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) ]
189             if not modes:
190                 raise RuntimeError("Parsing XRandR output failed, couldn't find any display modes")
191
192         options = {}
193         if not match["connected"]:
194             options["off"] = None
195             edid = None
196         elif not match["width"]:
197             options["off"] = None
198             edid = "".join(match["edid"].strip().split())
199         else:
200             if "mode_width" in match:
201                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
202             else:
203                 if match["rotate"] not in ("left", "right"):
204                     options["mode"] = "%sx%s" % (match["width"], match["height"])
205                 else:
206                     options["mode"] = "%sx%s" % (match["height"], match["width"])
207             if match["rotate"] != "normal":
208                 options["rotate"] = match["rotate"]
209             if "primary" in match and match["primary"]:
210                 options["primary"] = None
211             if "reflect" in match:
212                 if match["reflect"] == "X":
213                     options["reflect"] = "x"
214                 elif match["reflect"] == "Y":
215                     options["reflect"] = "y"
216                 elif match["reflect"] == "X and Y":
217                     options["reflect"] = "xy"
218             options["pos"] = "%sx%s" % (match["x"], match["y"])
219             if match["transform"]:
220                 transformation = ",".join(match["transform"].strip().split())
221                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
222                     options["transform"] = transformation
223                     if "mode_width" not in match:
224                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
225                         # special case is actually required.
226                         print("Warning: Output %s has a transformation applied. Could not determine correct mode!", file=sys.stderr)
227             if match["gamma"]:
228                 gamma = match["gamma"].strip()
229                 if gamma != "1.0:1.0:1.0":
230                     options["gamma"] = gamma
231             if match["rate"]:
232                 options["rate"] = match["rate"]
233             edid = "".join(match["edid"].strip().split())
234
235         return XrandrOutput(match["output"], edid, options), modes
236
237     @classmethod
238     def from_config_file(cls, edid_map, configuration):
239         "Instanciate an XrandrOutput from the contents of a configuration file"
240         options = {}
241         for line in configuration.split("\n"):
242             if line:
243                 line = line.split(None, 1)
244                 options[line[0]] = line[1] if len(line) > 1 else None
245         if "off" in options:
246             edid = None
247         else:
248             if options["output"] in edid_map:
249                 edid = edid_map[options["output"]]
250             else:
251                 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
252                 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
253                 if fuzzy_output not in fuzzy_edid_map:
254                     raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"])
255                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
256         output = options["output"]
257         del options["output"]
258
259         return XrandrOutput(output, edid, options)
260
261     def edid_equals(self, other):
262         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
263         if self.edid and other.edid:
264             if len(self.edid) == 32 and len(other.edid) != 32:
265                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
266             if len(self.edid) != 32 and len(other.edid) == 32:
267                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
268         return self.edid == other.edid
269
270     def __eq__(self, other):
271         return self.edid == other.edid and self.output == other.output and self.options == other.options
272
273 def xrandr_version():
274     "Return the version of XRandR that this system uses"
275     if getattr(xrandr_version, "version", False) is False:
276         version_string = os.popen("xrandr -v").read()
277         version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
278         xrandr_version.version = Version(version)
279     return xrandr_version.version
280
281 def debug_regexp(pattern, string):
282     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
283     try:
284         import regex
285         bounds = ( 0, len(string) )
286         while bounds[0] != bounds[1]:
287             half = int((bounds[0] + bounds[1]) / 2)
288             if half == bounds[0]:
289                 break
290             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
291         partial_length = bounds[0]
292         return ("Regular expression matched until position "
293               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
294                                                              string[partial_length:partial_length+10]))
295     except ImportError:
296         pass
297     return "Debug information available if `regex' module is installed."
298
299 def parse_xrandr_output():
300     "Parse the output of `xrandr --verbose' into a list of outputs"
301     xrandr_output = os.popen("xrandr -q --verbose").read()
302     if not xrandr_output:
303         raise RuntimeError("Failed to run xrandr")
304
305     # We are not interested in screens
306     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
307
308     # Split at output boundaries and instanciate an XrandrOutput per output
309     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
310     outputs = OrderedDict()
311     modes = OrderedDict()
312     for i in range(1, len(split_xrandr_output), 2):
313         output_name = split_xrandr_output[i].split()[0]
314         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
315         outputs[output_name] = output
316         if output_modes:
317             modes[output_name] = output_modes
318
319     return outputs, modes
320
321 def load_profiles(profile_path):
322     "Load the stored profiles"
323
324     profiles = {}
325     for profile in os.listdir(profile_path):
326         config_name = os.path.join(profile_path, profile, "config")
327         setup_name  = os.path.join(profile_path, profile, "setup")
328         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
329             continue
330
331         edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
332
333         config = {}
334         buffer = []
335         for line in chain(open(config_name).readlines(), ["output"]):
336             if line[:6] == "output" and buffer:
337                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
338                 buffer = [ line ]
339             else:
340                 buffer.append(line)
341
342         for output_name in list(config.keys()):
343             if "off" in config[output_name].options:
344                 del config[output_name]
345
346         profiles[profile] = config
347
348     return profiles
349
350 def find_profile(current_config, profiles):
351     "Find a profile matching the currently connected outputs"
352     for profile_name, profile in profiles.items():
353         matches = True
354         for name, output in profile.items():
355             if not output.edid:
356                 continue
357             if name not in current_config or not output.edid_equals(current_config[name]):
358                 matches = False
359                 break
360         if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
361             continue
362         if matches:
363             return profile_name
364
365 def profile_blocked(profile_path):
366     "Check if a profile is blocked"
367     script = os.path.join(profile_path, "blocked")
368     if not os.access(script, os.X_OK | os.F_OK):
369         return False
370     return subprocess.call(script) == 0
371
372 def output_configuration(configuration, config):
373     "Write a configuration file"
374     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
375     for output in outputs:
376         print(configuration[output].option_string, file=config)
377
378 def output_setup(configuration, setup):
379     "Write a setup (fingerprint) file"
380     outputs = sorted(configuration.keys())
381     for output in outputs:
382         if configuration[output].edid:
383             print(output, configuration[output].edid, file=setup)
384
385 def save_configuration(profile_path, configuration):
386     "Save a configuration into a profile"
387     if not os.path.isdir(profile_path):
388         os.makedirs(profile_path)
389     with open(os.path.join(profile_path, "config"), "w") as config:
390         output_configuration(configuration, config)
391     with open(os.path.join(profile_path, "setup"), "w") as setup:
392         output_setup(configuration, setup)
393
394 def apply_configuration(configuration, dry_run=False):
395     "Apply a configuration"
396     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
397     if dry_run:
398         base_argv = [ "echo", "xrandr" ]
399     else:
400         base_argv = [ "xrandr" ]
401
402     # Disable all unused outputs
403     argv = base_argv[:]
404     for output in outputs:
405         if not configuration[output].edid:
406             argv += configuration[output].option_vector
407     if argv != base_argv:
408         if subprocess.call(argv) != 0:
409             return False
410
411     # Enable remaining outputs in pairs of two
412     remaining_outputs = [ x for x in outputs if configuration[x].edid ]
413     for index in range(0, len(remaining_outputs), 2):
414         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:
415             return False
416
417 def add_unused_outputs(source_configuration, target_configuration):
418     "Add outputs that are missing in target to target, in 'off' state"
419     for output_name, output in source_configuration.items():
420         if output_name not in target_configuration:
421             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
422
423 def generate_virtual_profile(configuration, modes, profile_name):
424     "Generate one of the virtual profiles"
425     configuration = copy.deepcopy(configuration)
426     if profile_name == "common":
427         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
428         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
429         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
430         if common_resolution:
431             for output in configuration:
432                 configuration[output].options = {}
433                 if output in modes:
434                     configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
435                     configuration[output].options["pos"] = "0x0"
436                 else:
437                     configuration[output].options["off"] = None
438     elif profile_name in ("horizontal", "vertical"):
439         shift = 0
440         if profile_name == "horizontal":
441             shift_index = "width"
442             pos_specifier = "%sx0"
443         else:
444             shift_index = "height"
445             pos_specifier = "0x%s"
446
447         for output in configuration:
448             configuration[output].options = {}
449             if output in modes:
450                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
451                 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
452                 configuration[output].options["rate"] = mode["rate"]
453                 configuration[output].options["pos"] = pos_specifier % shift
454                 shift += int(mode[shift_index])
455             else:
456                 configuration[output].options["off"] = None
457     return configuration
458
459 def exit_help():
460     "Print help and exit"
461     print(help_text)
462     for profile in virtual_profiles:
463         print("  %-10s %s" % profile[:2])
464     sys.exit(0)
465
466 def exec_scripts(profile_path, script_name):
467     "Run userscripts"
468     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
469         if os.access(script, os.X_OK | os.F_OK):
470             subprocess.call(script)
471
472 def main(argv):
473     try:
474        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
475     except getopt.GetoptError as e:
476         print(str(e))
477         options = { "--help": True }
478
479     profile_path = os.path.expanduser("~/.autorandr")
480
481     try:
482         profiles = load_profiles(profile_path)
483     except Exception as e:
484         print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
485         sys.exit(1)
486
487     try:
488         config, modes = parse_xrandr_output()
489     except Exception as e:
490         print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
491         sys.exit(1)
492
493     if "--fingerprint" in options:
494         output_setup(config, sys.stdout)
495         sys.exit(0)
496
497     if "--config" in options:
498         output_configuration(config, sys.stdout)
499         sys.exit(0)
500
501     if "-s" in options:
502         options["--save"] = options["-s"]
503     if "--save" in options:
504         if options["--save"] in ( x[0] for x in virtual_profiles ):
505             print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
506             sys.exit(1)
507         try:
508             save_configuration(os.path.join(profile_path, options["--save"]), config)
509         except Exception as e:
510             print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
511             sys.exit(1)
512         print("Saved current configuration as profile '%s'" % options["--save"])
513         sys.exit(0)
514
515     if "-h" in options or "--help" in options:
516         exit_help()
517
518     detected_profile = find_profile(config, profiles)
519     load_profile = False
520
521     if "-l" in options:
522         options["--load"] = options["-l"]
523     if "--load" in options:
524         load_profile = options["--load"]
525     else:
526         for profile_name in profiles.keys():
527             if profile_blocked(os.path.join(profile_path, profile_name)):
528                 print("%s (blocked)" % profile_name)
529                 continue
530             if detected_profile == profile_name:
531                 print("%s (detected)" % profile_name)
532                 if "-c" in options or "--change" in options:
533                     load_profile = detected_profile
534             else:
535                 print(profile_name)
536
537     if "-d" in options:
538         options["--default"] = options["-d"]
539     if not load_profile and "--default" in options:
540         load_profile = options["--default"]
541
542     if load_profile:
543         if load_profile in ( x[0] for x in virtual_profiles ):
544             profile = generate_virtual_profile(config, modes, load_profile)
545         else:
546             try:
547                 profile = profiles[load_profile]
548             except KeyError:
549                 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
550                 sys.exit(1)
551         add_unused_outputs(config, profile)
552         if profile == config and not "-f" in options and not "--force" in options:
553             print("Config already loaded")
554             sys.exit(0)
555
556         try:
557             if "--dry-run" in options:
558                 apply_configuration(profile, True)
559             else:
560                 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
561                 apply_configuration(profile, False)
562                 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
563         except Exception as e:
564             print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
565             sys.exit(1)
566
567     sys.exit(0)
568
569 if __name__ == '__main__':
570     try:
571         main(sys.argv)
572     except Exception as e:
573         print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)
574         sys.exit(1)