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