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