]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Experimental Python implementation of autorandr
[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             (?P<rotation>[^(]\S+)?                                                      # Has a value if the output is rotated
83         ).*
84         (?:\s+(?:                                                                       # Properties of the output
85             Gamma: (?P<gamma>[0-9\.:\s]+) |                                             # Gamma value
86             Transform: (?P<transform>[0-9\.\s]+) |                                      # Transformation matrix
87             EDID: (?P<edid>[0-9a-f\s]+) |                                               # EDID of the output
88             (?![0-9])[^:\s]+:.*(?:\s\\t[\\t ].+)*                                       # Other properties
89         ))+
90         \s*
91         (?:  [0-9]+x[0-9]+.+?\*current.+\s+h:.+\s+v:.+clock\s+(?P<rate>[0-9\.]+)Hz\s* | # Interesting (current) resolution: Extract rate
92           [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s* |                                            # Other resolutions
93         )*
94         $
95     """
96
97     def __repr__(self):
98         return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector))
99
100     @property
101     def option_vector(self):
102         "Return the command line parameters for XRandR for this instance"
103         return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), self.options.items())], [])
104
105     @property
106     def option_string(self):
107         "Return the command line parameters in the configuration file format"
108         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())])
109
110     @property
111     def sort_key(self):
112         "Return a key to sort the outputs for xrandr invocation"
113         if not self.edid:
114             return -1
115         if "pos" in self.options:
116             x, y = map(float, self.options["pos"].split("x"))
117         else:
118             x, y = 0, 0
119         return x + 10000 * y
120
121     def __init__(self, output, edid, options):
122         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
123         self.output = output
124         self.edid = edid
125         self.options = options
126
127     @classmethod
128     def from_xrandr_output(cls, xrandr_output):
129         "Instanciate an XrandrOutput from the output of `xrandr --verbose'"
130         match = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output).groupdict()
131
132         options = {}
133         if not match["connected"]:
134             options["off"] = None
135             edid = None
136         else:
137             if not match["rotation"]:
138                 options["mode"] = "%sx%s" % (match["width"], match["height"])
139             else:
140                 options["mode"] = "%sx%s" % (match["height"], match["width"])
141             options["pos"] = "%sx%s" % (match["x"], match["y"])
142             if match["transform"]:
143                 transformation = ",".join(match["transform"].strip().split())
144                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
145                     options["transform"] = transformation
146                 else:
147                     options["transform"] = "none"
148             if match["gamma"]:
149                 gamma = match["gamma"].strip()
150                 if gamma != "1.0:1.0:1.0":
151                     options["gamma"] = gamma
152             if match["rate"]:
153                 options["rate"] = match["rate"]
154             edid = "".join(match["edid"].strip().split())
155
156         return XrandrOutput(match["output"], edid, options)
157
158     @classmethod
159     def from_config_file(cls, edid_map, configuration):
160         "Instanciate an XrandrOutput from the contents of a configuration file"
161         options = {}
162         for line in configuration.split("\n"):
163             if line:
164                 line = line.split(None, 1)
165                 options[line[0]] = line[1] if len(line) > 1 else None
166         if "off" in options:
167             edid = None
168         else:
169             if options["output"] in edid_map:
170                 edid = edid_map[options["output"]]
171             else:
172                 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
173                 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
174                 if fuzzy_output not in fuzzy_edid_map:
175                     raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"])
176                 edid = edid_map[edid_map.keys()[fuzzy_edid_map.index(fuzzy_output)]]
177         output = options["output"]
178         del options["output"]
179
180         return XrandrOutput(output, edid, options)
181
182     def edid_equals(self, other):
183         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
184         if self.edid and other.edid:
185             if len(self.edid) == 32 and len(other.edid) != 32:
186                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
187             if len(self.edid) != 32 and len(other.edid) == 32:
188                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
189         return self.edid == other.edid
190
191     def __eq__(self, other):
192         return self.edid == other.edid and self.output == other.output and self.options == other.options
193
194 def parse_xrandr_output():
195     "Parse the output of `xrandr --verbose' into a list of outputs"
196     xrandr_output = os.popen("xrandr -q --verbose").read()
197     if not xrandr_output:
198         raise RuntimeError("Failed to run xrandr")
199
200     # We are not interested in screens
201     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
202
203     # Split at output boundaries and instanciate an XrandrOutput per output
204     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
205     outputs = { split_xrandr_output[i].split()[0]: XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2])) for i in range(1, len(split_xrandr_output), 2) }
206
207     return outputs
208
209 def load_profiles(profile_path):
210     "Load the stored profiles"
211
212     profiles = {}
213     for profile in os.listdir(profile_path):
214         config_name = os.path.join(profile_path, profile, "config")
215         setup_name  = os.path.join(profile_path, profile, "setup")
216         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
217             continue
218
219         edids = dict([ x.strip().split() for x in open(setup_name).readlines() ])
220
221         config = {}
222         buffer = []
223         for line in chain(open(config_name).readlines(), ["output"]):
224             if line[:6] == "output" and buffer:
225                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
226                 buffer = [ line ]
227             else:
228                 buffer.append(line)
229
230         profiles[profile] = config
231
232     return profiles
233
234 def find_profile(current_config, profiles):
235     "Find a profile matching the currently connected outputs"
236     for profile_name, profile in profiles.items():
237         matches = True
238         for name, output in profile.items():
239             if not output.edid:
240                 continue
241             if name not in current_config or not output.edid_equals(current_config[name]):
242                 matches = False
243                 break
244         if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )):
245             continue
246         if matches:
247             return profile_name
248
249 def profile_blocked(profile_path):
250     "Check if a profile is blocked"
251     script = os.path.join(profile_path, "blocked")
252     if not os.access(script, os.X_OK | os.F_OK):
253         return False
254     return subprocess.call(script) == 0
255
256 def output_configuration(configuration, config):
257     "Write a configuration file"
258     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
259     for output in outputs:
260         print(configuration[output].option_string, file=config)
261
262 def output_setup(configuration, setup):
263     "Write a setup (fingerprint) file"
264     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
265     for output in outputs:
266         if configuration[output].edid:
267             print(output, configuration[output].edid, file=setup)
268
269 def save_configuration(profile_path, configuration):
270     "Save a configuration into a profile"
271     if not os.path.isdir(profile_path):
272         os.makedirs(profile_path)
273     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
274     with open(os.path.join(profile_path, "config"), "w") as config:
275         output_configuration(configuration, config)
276     with open(os.path.join(profile_path, "setup"), "w") as setup:
277         output_setup(configuration, setup)
278
279 def apply_configuration(configuration, dry_run=False):
280     "Apply a configuration"
281     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
282     if dry_run:
283         base_argv = [ "echo", "xrandr" ]
284     else:
285         base_argv = [ "xrandr" ]
286
287     # Disable all unused outputs
288     argv = base_argv[:]
289     for output in outputs:
290         if not configuration[output].edid:
291             argv += configuration[output].option_vector
292     if subprocess.call(argv) != 0:
293         return False
294
295     # Enable remaining outputs in pairs of two
296     remaining_outputs = [ x for x in outputs if configuration[x].edid ]
297     for index in range(0, len(remaining_outputs), 2):
298         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:
299             return False
300
301 def exit_help():
302     "Print help and exit"
303     print(help_text)
304     sys.exit(0)
305
306 def exec_scripts(profile_path, script_name):
307     "Run userscripts"
308     for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
309         if os.access(script, os.X_OK | os.F_OK):
310             subprocess.call(script)
311
312 def main(argv):
313     options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0])
314
315     profile_path = os.path.expanduser("~/.autorandr")
316     profiles = load_profiles(profile_path)
317     config = parse_xrandr_output()
318
319     if "--fingerprint" in options:
320         output_setup(config, sys.stdout)
321         sys.exit(0)
322
323     if "--config" in options:
324         output_configuration(config, sys.stdout)
325         sys.exit(0)
326
327     if "-s" in options:
328         options["--save"] = options["-s"]
329     if "--save" in options:
330         save_configuration(os.path.join(profile_path, options["--save"]), config)
331         print("Saved current configuration as profile '%s'" % options["--save"])
332         sys.exit(0)
333
334     if "-h" in options or "--help" in options:
335         exit_help()
336
337     detected_profile = find_profile(config, profiles)
338     load_profile = False
339
340     if "-l" in options:
341         options["--load"] = options["-l"]
342     if "--load" in options:
343         load_profile = options["--load"]
344
345     for profile_name in profiles.keys():
346         if profile_blocked(os.path.join(profile_path, profile_name)):
347             print("%s (blocked)" % profile_name)
348             continue
349         if detected_profile == profile_name:
350             print("%s (detected)" % profile_name)
351             if "-c" in options or "--change" in options:
352                 load_profile = detected_profile
353         else:
354             print(profile_name)
355
356     if "-d" in options:
357         options["--default"] = options["-d"]
358     if not load_profile and "--default" in options:
359         load_profile = options["--default"]
360
361     if load_profile:
362         profile = profiles[load_profile]
363         if profile == config and not "-f" in options and not "--force" in options:
364             print("Config already loaded")
365             sys.exit(0)
366
367         exec_scripts(os.path.join(profile_path, load_profile), "preswitch")
368         apply_configuration(profile, "--dry-run" in options)
369         exec_scripts(os.path.join(profile_path, load_profile), "postswitch")
370
371     sys.exit(0)
372
373 if __name__ == '__main__':
374     main(sys.argv)