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