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