]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Python version: Better error message for missing profiles
[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         for output_name, output in config.items():
326             if "off" in output.options:
327                 del config[output_name]
328
329         profiles[profile] = config
330
331     return profiles
332
333 def find_profile(current_config, profiles):
334     "Find a profile matching the currently connected outputs"
335     for profile_name, profile in profiles.items():
336         matches = True
337         for name, output in profile.items():
338             if not output.edid:
339                 continue
340             if name not in current_config or not output.edid_equals(current_config[name]):
341                 matches = False
342                 break
343         if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
344             continue
345         if matches:
346             return profile_name
347
348 def profile_blocked(profile_path):
349     "Check if a profile is blocked"
350     script = os.path.join(profile_path, "blocked")
351     if not os.access(script, os.X_OK | os.F_OK):
352         return False
353     return subprocess.call(script) == 0
354
355 def output_configuration(configuration, config):
356     "Write a configuration file"
357     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
358     for output in outputs:
359         print(configuration[output].option_string, file=config)
360
361 def output_setup(configuration, setup):
362     "Write a setup (fingerprint) file"
363     outputs = sorted(configuration.keys())
364     for output in outputs:
365         if configuration[output].edid:
366             print(output, configuration[output].edid, file=setup)
367
368 def save_configuration(profile_path, configuration):
369     "Save a configuration into a profile"
370     if not os.path.isdir(profile_path):
371         os.makedirs(profile_path)
372     with open(os.path.join(profile_path, "config"), "w") as config:
373         output_configuration(configuration, config)
374     with open(os.path.join(profile_path, "setup"), "w") as setup:
375         output_setup(configuration, setup)
376
377 def apply_configuration(configuration, dry_run=False):
378     "Apply a configuration"
379     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
380     if dry_run:
381         base_argv = [ "echo", "xrandr" ]
382     else:
383         base_argv = [ "xrandr" ]
384
385     # Disable all unused outputs
386     argv = base_argv[:]
387     for output in outputs:
388         if not configuration[output].edid:
389             argv += configuration[output].option_vector
390     if argv != base_argv:
391         if subprocess.call(argv) != 0:
392             return False
393
394     # Enable remaining outputs in pairs of two
395     remaining_outputs = [ x for x in outputs if configuration[x].edid ]
396     for index in range(0, len(remaining_outputs), 2):
397         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:
398             return False
399
400 def add_unused_outputs(source_configuration, target_configuration):
401     "Add outputs that are missing in target to target, in 'off' state"
402     for output_name, output in source_configuration.items():
403         if output_name not in target_configuration:
404             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
405
406 def generate_virtual_profile(configuration, modes, profile_name):
407     "Generate one of the virtual profiles"
408     configuration = copy.deepcopy(configuration)
409     if profile_name == "common":
410         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output )) for output in modes.values() ]
411         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
412         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
413         if common_resolution:
414             for output in configuration:
415                 configuration[output].options = {}
416                 if output in modes:
417                     configuration[output].options["mode"] = "%sx%s" % common_resolution[-1]
418                     configuration[output].options["pos"] = "0x0"
419                 else:
420                     configuration[output].options["off"] = None
421     elif profile_name in ("horizontal", "vertical"):
422         shift = 0
423         if profile_name == "horizontal":
424             shift_index = "width"
425             pos_specifier = "%sx0"
426         else:
427             shift_index = "height"
428             pos_specifier = "0x%s"
429
430         for output in configuration:
431             configuration[output].options = {}
432             if output in modes:
433                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
434                 configuration[output].options["mode"] = "%sx%s" % (mode["width"], mode["height"])
435                 configuration[output].options["rate"] = mode["rate"]
436                 configuration[output].options["pos"] = pos_specifier % shift
437                 shift += int(mode[shift_index])
438             else:
439                 configuration[output].options["off"] = None
440     return configuration
441
442 def exit_help():
443     "Print help and exit"
444     print(help_text)
445     for profile in virtual_profiles:
446         print("  %-10s %s" % profile[:2])
447     sys.exit(0)
448
449 def exec_scripts(profile_path, script_name):
450     "Run userscripts"
451     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
452         if os.access(script, os.X_OK | os.F_OK):
453             subprocess.call(script)
454
455 def main(argv):
456     try:
457        options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
458     except getopt.GetoptError as e:
459         print(str(e))
460         options = { "--help": True }
461
462     profile_path = os.path.expanduser("~/.autorandr")
463
464     try:
465         profiles = load_profiles(profile_path)
466     except Exception as e:
467         print("Failed to load profiles:\n%s" % str(e), file=sys.stderr)
468         sys.exit(1)
469
470     try:
471         config, modes = parse_xrandr_output()
472     except Exception as e:
473         print("Failed to parse current configuration from XRandR:\n%s" % str(e), file=sys.stderr)
474         sys.exit(1)
475
476     if "--fingerprint" in options:
477         output_setup(config, sys.stdout)
478         sys.exit(0)
479
480     if "--config" in options:
481         output_configuration(config, sys.stdout)
482         sys.exit(0)
483
484     if "-s" in options:
485         options["--save"] = options["-s"]
486     if "--save" in options:
487         if options["--save"] in ( x[0] for x in virtual_profiles ):
488             print("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
489             sys.exit(1)
490         try:
491             save_configuration(os.path.join(profile_path, options["--save"]), config)
492         except Exception as e:
493             print("Failed to save current configuration as profile '%s':\n%s" % (options["--save"], str(e)), file=sys.stderr)
494             sys.exit(1)
495         print("Saved current configuration as profile '%s'" % options["--save"])
496         sys.exit(0)
497
498     if "-h" in options or "--help" in options:
499         exit_help()
500
501     detected_profile = find_profile(config, profiles)
502     load_profile = False
503
504     if "-l" in options:
505         options["--load"] = options["-l"]
506     if "--load" in options:
507         load_profile = options["--load"]
508     else:
509         for profile_name in profiles.keys():
510             if profile_blocked(os.path.join(profile_path, profile_name)):
511                 print("%s (blocked)" % profile_name)
512                 continue
513             if detected_profile == profile_name:
514                 print("%s (detected)" % profile_name)
515                 if "-c" in options or "--change" in options:
516                     load_profile = detected_profile
517             else:
518                 print(profile_name)
519
520     if "-d" in options:
521         options["--default"] = options["-d"]
522     if not load_profile and "--default" in options:
523         load_profile = options["--default"]
524
525     if load_profile:
526         if load_profile in ( x[0] for x in virtual_profiles ):
527             profile = generate_virtual_profile(config, modes, load_profile)
528         else:
529             try:
530                 profile = profiles[load_profile]
531             except KeyError:
532                 print("Failed to load profile '%s':\nProfile not found" % load_profile, file=sys.stderr)
533                 sys.exit(1)
534         add_unused_outputs(config, profile)
535         if profile == config and not "-f" in options and not "--force" in options:
536             print("Config already loaded")
537             sys.exit(0)
538
539         try:
540             if "--dry-run" in options:
541                 apply_configuration(profile, True)
542             else:
543                 exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
544                 apply_configuration(profile, False)
545                 exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
546         except Exception as e:
547             print("Failed to apply profile '%s':\n%s" % (load_profile, str(e)), file=sys.stderr)
548             sys.exit(1)
549
550     sys.exit(0)
551
552 if __name__ == '__main__':
553     try:
554         main(sys.argv)
555     except Exception as e:
556         print("General failure. Please report this as a bug:\n%s" % (str(e),), file=sys.stderr)
557         sys.exit(1)