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