From 1d41e39939b126e1ce5e10ec1f307aa589f37983 Mon Sep 17 00:00:00 2001 From: Phillip Berndt Date: Tue, 6 Jan 2015 21:13:13 +0100 Subject: [PATCH] Experimental Python implementation of autorandr Still incomplete, virtual profiles are missing. --- autorandr.py | 374 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100755 autorandr.py diff --git a/autorandr.py b/autorandr.py new file mode 100755 index 0000000..f70fc91 --- /dev/null +++ b/autorandr.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# autorandr.py +# Copyright (c) 2015, Phillip Berndt +# +# Experimental autorandr rewrite in Python +# +# This script aims to be fully compatible with the original autorandr. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# TODO Add virtual profiles common, horizontal, vertical +# This also requires to load all resolutions into the XrandrOutputs + +from __future__ import print_function +import getopt + +import binascii +import hashlib +import os +import re +import subprocess +import sys + +from itertools import chain + +help_text = """ +Usage: autorandr [options] + +-h, --help get this small help +-c, --change reload current setup +-s, --save save your current setup to profile +-l, --load load profile +-d, --default make profile the default profile +--force force (re)loading of a profile +--fingerprint fingerprint your current hardware setup +--config dump your current xrandr setup +--dry-run don't change anything, only print the xrandr commands + + To prevent a profile from being loaded, place a script call "block" in its + directory. The script is evaluated before the screen setup is inspected, and + in case of it returning a value of 0 the profile is skipped. This can be used + to query the status of a docking station you are about to leave. + + If no suitable profile can be identified, the current configuration is kept. + To change this behaviour and switch to a fallback configuration, specify + --default . + + Another script called "postswitch "can be placed in the directory + ~/.autorandr as well as in any profile directories: The scripts are executed + after a mode switch has taken place and can notify window managers. + + The following virtual configurations are available: + TODO +""".strip() + +class XrandrOutput(object): + "Represents an XRandR output" + + # This regular expression is used to parse an output in `xrandr --verbose' + XRANDR_OUTPUT_REGEXP = """(?x) + ^(?P[^ ]+)\s+ # Line starts with output name + (?: # Differentiate disconnected and connected in first line + disconnected | + unknown\ connection | + (?Pconnected)\s+ # If connected: + (?Pprimary\ )? # Might be primary screen + (?P[0-9]+)x(?P[0-9]+) # Resolution + \+(?P[0-9]+)\+(?P[0-9]+)\s+ # Position + (?P[^(]\S+)? # Has a value if the output is rotated + ).* + (?:\s+(?: # Properties of the output + Gamma: (?P[0-9\.:\s]+) | # Gamma value + Transform: (?P[0-9\.\s]+) | # Transformation matrix + EDID: (?P[0-9a-f\s]+) | # EDID of the output + (?![0-9])[^:\s]+:.*(?:\s\\t[\\t ].+)* # Other properties + ))+ + \s* + (?: [0-9]+x[0-9]+.+?\*current.+\s+h:.+\s+v:.+clock\s+(?P[0-9\.]+)Hz\s* | # Interesting (current) resolution: Extract rate + [0-9]+x[0-9]+.+\s+h:.+\s+v:.+\s* | # Other resolutions + )* + $ + """ + + def __repr__(self): + return "<%s%s %s>" % (self.output, (" %s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else "", " ".join(self.option_vector)) + + @property + def option_vector(self): + "Return the command line parameters for XRandR for this instance" + return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), self.options.items())], []) + + @property + def option_string(self): + "Return the command line parameters in the configuration file format" + return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), self.options.items())]) + + @property + def sort_key(self): + "Return a key to sort the outputs for xrandr invocation" + if not self.edid: + return -1 + if "pos" in self.options: + x, y = map(float, self.options["pos"].split("x")) + else: + x, y = 0, 0 + return x + 10000 * y + + def __init__(self, output, edid, options): + "Instanciate using output name, edid and a dictionary of XRandR command line parameters" + self.output = output + self.edid = edid + self.options = options + + @classmethod + def from_xrandr_output(cls, xrandr_output): + "Instanciate an XrandrOutput from the output of `xrandr --verbose'" + match = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output).groupdict() + + options = {} + if not match["connected"]: + options["off"] = None + edid = None + else: + if not match["rotation"]: + options["mode"] = "%sx%s" % (match["width"], match["height"]) + else: + options["mode"] = "%sx%s" % (match["height"], match["width"]) + options["pos"] = "%sx%s" % (match["x"], match["y"]) + if match["transform"]: + transformation = ",".join(match["transform"].strip().split()) + if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000": + options["transform"] = transformation + else: + options["transform"] = "none" + if match["gamma"]: + gamma = match["gamma"].strip() + if gamma != "1.0:1.0:1.0": + options["gamma"] = gamma + if match["rate"]: + options["rate"] = match["rate"] + edid = "".join(match["edid"].strip().split()) + + return XrandrOutput(match["output"], edid, options) + + @classmethod + def from_config_file(cls, edid_map, configuration): + "Instanciate an XrandrOutput from the contents of a configuration file" + options = {} + for line in configuration.split("\n"): + if line: + line = line.split(None, 1) + options[line[0]] = line[1] if len(line) > 1 else None + if "off" in options: + edid = None + else: + if options["output"] in edid_map: + edid = edid_map[options["output"]] + else: + fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ] + fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"]) + if fuzzy_output not in fuzzy_edid_map: + raise RuntimeError("Failed to find a corresponding output in config/setup for output `%s'" % options["output"]) + edid = edid_map[edid_map.keys()[fuzzy_edid_map.index(fuzzy_output)]] + output = options["output"] + del options["output"] + + return XrandrOutput(output, edid, options) + + def edid_equals(self, other): + "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account" + if self.edid and other.edid: + if len(self.edid) == 32 and len(other.edid) != 32: + return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid + if len(self.edid) != 32 and len(other.edid) == 32: + return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid + return self.edid == other.edid + + def __eq__(self, other): + return self.edid == other.edid and self.output == other.output and self.options == other.options + +def parse_xrandr_output(): + "Parse the output of `xrandr --verbose' into a list of outputs" + xrandr_output = os.popen("xrandr -q --verbose").read() + if not xrandr_output: + raise RuntimeError("Failed to run xrandr") + + # We are not interested in screens + xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip() + + # Split at output boundaries and instanciate an XrandrOutput per output + split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output) + 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) } + + return outputs + +def load_profiles(profile_path): + "Load the stored profiles" + + profiles = {} + for profile in os.listdir(profile_path): + config_name = os.path.join(profile_path, profile, "config") + setup_name = os.path.join(profile_path, profile, "setup") + if not os.path.isfile(config_name) or not os.path.isfile(setup_name): + continue + + edids = dict([ x.strip().split() for x in open(setup_name).readlines() ]) + + config = {} + buffer = [] + for line in chain(open(config_name).readlines(), ["output"]): + if line[:6] == "output" and buffer: + config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer)) + buffer = [ line ] + else: + buffer.append(line) + + profiles[profile] = config + + return profiles + +def find_profile(current_config, profiles): + "Find a profile matching the currently connected outputs" + for profile_name, profile in profiles.items(): + matches = True + for name, output in profile.items(): + if not output.edid: + continue + if name not in current_config or not output.edid_equals(current_config[name]): + matches = False + break + if not matches or any(( name not in profile.keys() for name in current_config.keys() if current_config[name].edid )): + continue + if matches: + return profile_name + +def profile_blocked(profile_path): + "Check if a profile is blocked" + script = os.path.join(profile_path, "blocked") + if not os.access(script, os.X_OK | os.F_OK): + return False + return subprocess.call(script) == 0 + +def output_configuration(configuration, config): + "Write a configuration file" + outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key) + for output in outputs: + print(configuration[output].option_string, file=config) + +def output_setup(configuration, setup): + "Write a setup (fingerprint) file" + outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key) + for output in outputs: + if configuration[output].edid: + print(output, configuration[output].edid, file=setup) + +def save_configuration(profile_path, configuration): + "Save a configuration into a profile" + if not os.path.isdir(profile_path): + os.makedirs(profile_path) + outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key) + with open(os.path.join(profile_path, "config"), "w") as config: + output_configuration(configuration, config) + with open(os.path.join(profile_path, "setup"), "w") as setup: + output_setup(configuration, setup) + +def apply_configuration(configuration, dry_run=False): + "Apply a configuration" + outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key) + if dry_run: + base_argv = [ "echo", "xrandr" ] + else: + base_argv = [ "xrandr" ] + + # Disable all unused outputs + argv = base_argv[:] + for output in outputs: + if not configuration[output].edid: + argv += configuration[output].option_vector + if subprocess.call(argv) != 0: + return False + + # Enable remaining outputs in pairs of two + remaining_outputs = [ x for x in outputs if configuration[x].edid ] + for index in range(0, len(remaining_outputs), 2): + 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: + return False + +def exit_help(): + "Print help and exit" + print(help_text) + sys.exit(0) + +def exec_scripts(profile_path, script_name): + "Run userscripts" + for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)): + if os.access(script, os.X_OK | os.F_OK): + subprocess.call(script) + +def main(argv): + options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "help" ])[0]) + + profile_path = os.path.expanduser("~/.autorandr") + profiles = load_profiles(profile_path) + config = parse_xrandr_output() + + if "--fingerprint" in options: + output_setup(config, sys.stdout) + sys.exit(0) + + if "--config" in options: + output_configuration(config, sys.stdout) + sys.exit(0) + + if "-s" in options: + options["--save"] = options["-s"] + if "--save" in options: + save_configuration(os.path.join(profile_path, options["--save"]), config) + print("Saved current configuration as profile '%s'" % options["--save"]) + sys.exit(0) + + if "-h" in options or "--help" in options: + exit_help() + + detected_profile = find_profile(config, profiles) + load_profile = False + + if "-l" in options: + options["--load"] = options["-l"] + if "--load" in options: + load_profile = options["--load"] + + for profile_name in profiles.keys(): + if profile_blocked(os.path.join(profile_path, profile_name)): + print("%s (blocked)" % profile_name) + continue + if detected_profile == profile_name: + print("%s (detected)" % profile_name) + if "-c" in options or "--change" in options: + load_profile = detected_profile + else: + print(profile_name) + + if "-d" in options: + options["--default"] = options["-d"] + if not load_profile and "--default" in options: + load_profile = options["--default"] + + if load_profile: + profile = profiles[load_profile] + if profile == config and not "-f" in options and not "--force" in options: + print("Config already loaded") + sys.exit(0) + + exec_scripts(os.path.join(profile_path, load_profile), "preswitch") + apply_configuration(profile, "--dry-run" in options) + exec_scripts(os.path.join(profile_path, load_profile), "postswitch") + + sys.exit(0) + +if __name__ == '__main__': + main(sys.argv)