]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
allow legacy autorandr to be off while connected
[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
246         edid = None
247
248         if options["output"] in edid_map:
249             edid = edid_map[options["output"]]
250         else:
251             # This fuzzy matching is for legacy autorandr that used sysfs output names
252             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
253             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
254             if fuzzy_output in fuzzy_edid_map:
255                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
256             elif "off" not in options:
257                 raise RuntimeError("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file."
258                                    % (options["output"], options["output"]))
259         output = options["output"]
260         del options["output"]
261
262         return XrandrOutput(output, edid, options)
263
264     def edid_equals(self, other):
265         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
266         if self.edid and other.edid:
267             if len(self.edid) == 32 and len(other.edid) != 32:
268                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
269             if len(self.edid) != 32 and len(other.edid) == 32:
270                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
271         return self.edid == other.edid
272
273     def __eq__(self, other):
274         return self.edid == other.edid and self.output == other.output and self.options == other.options
275
276 def xrandr_version():
277     "Return the version of XRandR that this system uses"
278     if getattr(xrandr_version, "version", False) is False:
279         version_string = os.popen("xrandr -v").read()
280         version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
281         xrandr_version.version = Version(version)
282     return xrandr_version.version
283
284 def debug_regexp(pattern, string):
285     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
286     try:
287         import regex
288         bounds = ( 0, len(string) )
289         while bounds[0] != bounds[1]:
290             half = int((bounds[0] + bounds[1]) / 2)
291             if half == bounds[0]:
292                 break
293             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
294         partial_length = bounds[0]
295         return ("Regular expression matched until position "
296               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
297                                                              string[partial_length:partial_length+10]))
298     except ImportError:
299         pass
300     return "Debug information available if `regex' module is installed."
301
302 def parse_xrandr_output():
303     "Parse the output of `xrandr --verbose' into a list of outputs"
304     xrandr_output = os.popen("xrandr -q --verbose").read()
305     if not xrandr_output:
306         raise RuntimeError("Failed to run xrandr")
307
308     # We are not interested in screens
309     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
310
311     # Split at output boundaries and instanciate an XrandrOutput per output
312     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
313     outputs = OrderedDict()
314     modes = OrderedDict()
315     for i in range(1, len(split_xrandr_output), 2):
316         output_name = split_xrandr_output[i].split()[0]
317         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
318         outputs[output_name] = output
319         if output_modes:
320             modes[output_name] = output_modes
321
322     return outputs, modes
323
324 def load_profiles(profile_path):
325     "Load the stored profiles"
326
327     profiles = {}
328     for profile in os.listdir(profile_path):
329         config_name = os.path.join(profile_path, profile, "config")
330         setup_name  = os.path.join(profile_path, profile, "setup")
331         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
332             continue
333
334         edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
335
336         config = {}
337         buffer = []
338         for line in chain(open(config_name).readlines(), ["output"]):
339             if line[:6] == "output" and buffer:
340                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
341                 buffer = [ line ]
342             else:
343                 buffer.append(line)
344
345         for output_name in list(config.keys()):
346             if config[output_name].edid is None:
347                 del config[output_name]
348
349         profiles[profile] = config
350
351     return profiles
352
353 def find_profile(current_config, profiles):
354     "Find a profile matching the currently connected outputs"
355     for profile_name, profile in profiles.items():
356         matches = True
357         for name, output in profile.items():
358             if not output.edid:
359                 continue
360             if name not in current_config or not output.edid_equals(current_config[name]):
361                 matches = False
362                 break
363         if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
364             continue
365         if matches:
366             return profile_name
367
368 def profile_blocked(profile_path):
369     "Check if a profile is blocked"
370     script = os.path.join(profile_path, "blocked")
371     if not os.access(script, os.X_OK | os.F_OK):
372         return False
373     return subprocess.call(script) == 0
374
375 def output_configuration(configuration, config):
376     "Write a configuration file"
377     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
378     for output in outputs:
379         print(configuration[output].option_string, file=config)
380
381 def output_setup(configuration, setup):
382     "Write a setup (fingerprint) file"
383     outputs = sorted(configuration.keys())
384     for output in outputs:
385         if configuration[output].edid:
386             print(output, configuration[output].edid, file=setup)
387
388 def save_configuration(profile_path, configuration):
389     "Save a configuration into a profile"
390     if not os.path.isdir(profile_path):
391         os.makedirs(profile_path)
392     with open(os.path.join(profile_path, "config"), "w") as config:
393         output_configuration(configuration, config)
394     with open(os.path.join(profile_path, "setup"), "w") as setup:
395         output_setup(configuration, setup)
396
397 def apply_configuration(configuration, dry_run=False):
398     "Apply a configuration"
399     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
400     if dry_run:
401         base_argv = [ "echo", "xrandr" ]
402     else:
403         base_argv = [ "xrandr" ]
404
405     # Disable all unused outputs
406     argv = base_argv[:]
407     for output in outputs:
408         if not configuration[output].edid:
409             argv += configuration[output].option_vector
410     if argv != base_argv:
411         if subprocess.call(argv) != 0:
412             return False
413
414     # Enable remaining outputs in pairs of two
415     remaining_outputs = [ x for x in outputs if configuration[x].edid ]
416     for index in range(0, len(remaining_outputs), 2):
417         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:
418             return False
419
420 def add_unused_outputs(source_configuration, target_configuration):
421     "Add outputs that are missing in target to target, in 'off' state"
422     for output_name, output in source_configuration.items():
423         if output_name not in target_configuration:
424             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
425
426 def generate_virtual_profile(configuration, modes, profile_name):
427     "Generate one of the virtual profiles"
428     configuration = copy.deepcopy(configuration)
429     if profile_name == "common":
430         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
431         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
432         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
433         if common_resolution:
434             for output in configuration:
435                 configuration[output].options = {}
436                 if output in modes:
437                     configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
438                     configuration[output].options["pos"] = "0x0"
439                 else:
440                     configuration[output].options["off"] = None
441     elif profile_name in ("horizontal", "vertical"):
442         shift = 0
443         if profile_name == "horizontal":
444             shift_index = "width"
445             pos_specifier = "%sx0"
446         else:
447             shift_index = "height"
448             pos_specifier = "0x%s"
449
450         for output in configuration:
451             configuration[output].options = {}
452             if output in modes:
453                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
454                 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
455                 configuration[output].options["rate"] = mode["rate"]
456                 configuration[output].options["pos"] = pos_specifier % shift
457                 shift += int(mode[shift_index])
458             else:
459                 configuration[output].options["off"] = None
460     return configuration
461
462 def exit_help():
463     "Print help and exit"
464     print(help_text)
465     for profile in virtual_profiles:
466         print("  %-10s %s" % profile[:2])
467     sys.exit(0)
468
469 def exec_scripts(profile_path, script_name):
470     "Run userscripts"
471     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
472         if os.access(script, os.X_OK | os.F_OK):
473             subprocess.call(script)
474
475 def main(argv):
476     try:
477        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
478     except getopt.GetoptError as e:
479         print(str(e))
480         options = { "--help": True }
481
482     profile_path = os.path.expanduser("~/.autorandr")
483
484     try:
485         profiles = load_profiles(profile_path)
486     except Exception as e:
487         print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
488         sys.exit(1)
489
490     try:
491         config, modes = parse_xrandr_output()
492     except Exception as e:
493         print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
494         sys.exit(1)
495
496     if "--fingerprint" in options:
497         output_setup(config, sys.stdout)
498         sys.exit(0)
499
500     if "--config" in options:
501         output_configuration(config, sys.stdout)
502         sys.exit(0)
503
504     if "-s" in options:
505         options["--save"] = options["-s"]
506     if "--save" in options:
507         if options["--save"] in ( x[0] for x in virtual_profiles ):
508             print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
509             sys.exit(1)
510         try:
511             save_configuration(os.path.join(profile_path, options["--save"]), config)
512         except Exception as e:
513             print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
514             sys.exit(1)
515         print("Saved current configuration as profile '%s'" % options["--save"])
516         sys.exit(0)
517
518     if "-h" in options or "--help" in options:
519         exit_help()
520
521     detected_profile = find_profile(config, profiles)
522     load_profile = False
523
524     if "-l" in options:
525         options["--load"] = options["-l"]
526     if "--load" in options:
527         load_profile = options["--load"]
528     else:
529         for profile_name in profiles.keys():
530             if profile_blocked(os.path.join(profile_path, profile_name)):
531                 print("%s (blocked)" % profile_name)
532                 continue
533             if detected_profile == profile_name:
534                 print("%s (detected)" % profile_name)
535                 if "-c" in options or "--change" in options:
536                     load_profile = detected_profile
537             else:
538                 print(profile_name)
539
540     if "-d" in options:
541         options["--default"] = options["-d"]
542     if not load_profile and "--default" in options:
543         load_profile = options["--default"]
544
545     if load_profile:
546         if load_profile in ( x[0] for x in virtual_profiles ):
547             profile = generate_virtual_profile(config, modes, load_profile)
548         else:
549             try:
550                 profile = profiles[load_profile]
551             except KeyError:
552                 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
553                 sys.exit(1)
554         add_unused_outputs(config, profile)
555         if profile == config and not "-f" in options and not "--force" in options:
556             print("Config already loaded")
557             sys.exit(0)
558
559         try:
560             if "--dry-run" in options:
561                 apply_configuration(profile, True)
562             else:
563                 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
564                 apply_configuration(profile, False)
565                 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
566         except Exception as e:
567             print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
568             sys.exit(1)
569
570     sys.exit(0)
571
572 if __name__ == '__main__':
573     try:
574         main(sys.argv)
575     except Exception as e:
576         print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)
577         sys.exit(1)