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