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