forked from phillipberndt/autorandr
-
Notifications
You must be signed in to change notification settings - Fork 0
/
autorandr.py
executable file
·1480 lines (1295 loc) · 65.3 KB
/
autorandr.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python
# encoding: utf-8
#
# autorandr.py
# Copyright (c) 2015, Phillip Berndt
#
# 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 <http://www.gnu.org/licenses/>.
#
from __future__ import print_function
import binascii
import copy
import getopt
import hashlib
import os
import posix
import pwd
import re
import subprocess
import sys
import shutil
import time
import glob
from collections import OrderedDict
from distutils.version import LooseVersion as Version
from functools import reduce
from itertools import chain
if sys.version_info.major == 2:
import ConfigParser as configparser
else:
import configparser
__version__ = "1.11"
try:
input = raw_input
except NameError:
pass
virtual_profiles = [
# (name, description, callback)
("off", "Disable all outputs", None),
("common", "Clone all connected outputs at the largest common resolution", None),
("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
("vertical", "Stack all connected outputs vertically at their largest resolution", None),
]
help_text = """
Usage: autorandr [options]
-h, --help get this small help
-c, --change automatically load the first detected profile
-d, --default <profile> make profile <profile> the default profile
-l, --load <profile> load profile <profile>
-s, --save <profile> save your current setup to profile <profile>
-r, --remove <profile> remove profile <profile>
--batch run autorandr for all users with active X11 sessions
--current only list current (active) configuration(s)
--config dump your current xrandr setup
--debug enable verbose output
--detected only list detected (available) configuration(s)
--dry-run don't change anything, only print the xrandr commands
--fingerprint fingerprint your current hardware setup
--force force (re)loading of a profile / overwrite exiting files
--skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
to skip both in detecting changes and applying a profile
--version show version information and exit
If no suitable profile can be identified, the current configuration is kept.
To change this behaviour and switch to a fallback configuration, specify
--default <profile>.
autorandr supports a set of per-profile and global hooks. See the documentation
for details.
The following virtual configurations are available:
""".strip()
def is_closed_lid(output):
if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
return False
lids = glob.glob("/proc/acpi/button/lid/*/state")
if len(lids) == 1:
state_file = lids[0]
with open(state_file) as f:
content = f.read()
return "close" in content
return False
class AutorandrException(Exception):
def __init__(self, message, original_exception=None, report_bug=False):
self.message = message
self.report_bug = report_bug
if original_exception:
self.original_exception = original_exception
trace = sys.exc_info()[2]
while trace.tb_next:
trace = trace.tb_next
self.line = trace.tb_lineno
self.file_name = trace.tb_frame.f_code.co_filename
else:
try:
import inspect
frame = inspect.currentframe().f_back
self.line = frame.f_lineno
self.file_name = frame.f_code.co_filename
except:
self.line = None
self.file_name = None
self.original_exception = None
if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
self.file_name = None
def __str__(self):
retval = [self.message]
if self.line:
retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
if self.original_exception:
retval.append(":\n ")
retval.append(str(self.original_exception).replace("\n", "\n "))
if self.report_bug:
retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
"\nhttps://github.com/phillipberndt/autorandr/issues"
"\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
return "".join(retval)
class XrandrOutput(object):
"Represents an XRandR output"
# This regular expression is used to parse an output in `xrandr --verbose'
XRANDR_OUTPUT_REGEXP = """(?x)
^\s*(?P<output>\S[^ ]*)\s+ # Line starts with output name
(?: # Differentiate disconnected and connected
disconnected | # in first line
unknown\ connection |
(?P<connected>connected)
)
\s*
(?P<primary>primary\ )? # Might be primary screen
(?:\s*
(?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
\+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
(?:\(0x[0-9a-fA-F]+\)\s+)? # XID
(?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
(?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
)? # .. but only if the screen is in use.
(?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
(?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
(?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
(?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
(?:\s*(?: # Properties of the output
Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) | # Gamma value
CRTC:\s*(?P<crtc>[0-9]) | # CRTC value
Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
(?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
))+
\s*
(?P<modes>(?:
(?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
\S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
)*)
"""
XRANDR_OUTPUT_MODES_REGEXP = """(?x)
(?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
h:\s+width\s+(?P<width>[0-9]+).+\s+
v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
"""
XRANDR_13_DEFAULTS = {
"transform": "1,0,0,0,1,0,0,0,1",
"panning": "0x0",
}
XRANDR_12_DEFAULTS = {
"reflect": "normal",
"rotate": "normal",
"gamma": "1.0:1.0:1.0",
}
XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
def __repr__(self):
return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
@property
def short_edid(self):
return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
@property
def options_with_defaults(self):
"Return the options dictionary, augmented with the default values that weren't set"
if "off" in self.options:
return self.options
options = {}
if xrandr_version() >= Version("1.3"):
options.update(self.XRANDR_13_DEFAULTS)
if xrandr_version() >= Version("1.2"):
options.update(self.XRANDR_12_DEFAULTS)
options.update(self.options)
return {a: b for a, b in options.items() if a not in self.ignored_options}
@property
def filtered_options(self):
"Return a dictionary of options without ignored options"
return {a: b for a, b in self.options.items() if a not in self.ignored_options}
@property
def option_vector(self):
"Return the command line parameters for XRandR for this instance"
args = ["--output", self.output]
for option, arg in sorted(self.options_with_defaults.items()):
args.append("--%s" % option)
if arg:
args.append(arg)
return args
@property
def option_string(self):
"Return the command line parameters in the configuration file format"
options = ["output %s" % self.output]
for option, arg in sorted(self.filtered_options.items()):
if arg:
options.append("%s %s" % (option, arg))
else:
options.append(option)
return "\n".join(options)
@property
def sort_key(self):
"Return a key to sort the outputs for xrandr invocation"
if not self.edid:
return -2
if "off" in self.options:
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
self.ignored_options = []
self.remove_default_option_values()
def set_ignored_options(self, options):
"Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
self.ignored_options = list(options)
def remove_default_option_values(self):
"Remove values from the options dictionary that are superflous"
if "off" in self.options and len(self.options.keys()) > 1:
self.options = {"off": None}
return
for option, default_value in self.XRANDR_DEFAULTS.items():
if option in self.options and self.options[option] == default_value:
del self.options[option]
@classmethod
def from_xrandr_output(cls, xrandr_output):
"""Instanciate an XrandrOutput from the output of `xrandr --verbose'
This method also returns a list of modes supported by the output.
"""
try:
xrandr_output = xrandr_output.replace("\r\n", "\n")
match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
except:
raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
report_bug=True)
if not match_object:
debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
report_bug=True)
remainder = xrandr_output[len(match_object.group(0)):]
if remainder:
raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
"regular expression, starting at byte %d with ..'%s'." %
(len(remainder), len(match_object.group(0)), remainder[:10]),
report_bug=True)
match = match_object.groupdict()
modes = []
if match["modes"]:
modes = []
for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
if mode_match.group("name"):
modes.append(mode_match.groupdict())
if not modes:
raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
options = {}
if not match["connected"]:
edid = None
elif match["edid"]:
edid = "".join(match["edid"].strip().split())
else:
edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
# An output can be disconnected but still have a mode configured. This can only happen
# as a residual situation after a disconnect, you cannot associate a mode with an disconnected
# output.
#
# This code needs to be careful not to mix the two. An output should only be configured to
# "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
if not match["width"]:
options["off"] = None
else:
if match["mode_name"]:
options["mode"] = match["mode_name"]
elif match["mode_width"]:
options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
else:
if match["rotate"] not in ("left", "right"):
options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
else:
options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
if match["rotate"]:
options["rotate"] = match["rotate"]
if match["primary"]:
options["primary"] = None
if match["reflect"] == "X":
options["reflect"] = "x"
elif match["reflect"] == "Y":
options["reflect"] = "y"
elif match["reflect"] == "X and Y":
options["reflect"] = "xy"
if match["x"] or match["y"]:
options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
if match["panning"]:
panning = [match["panning"]]
if match["tracking"]:
panning += ["/", match["tracking"]]
if match["border"]:
panning += ["/", match["border"]]
options["panning"] = "".join(panning)
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
if not match["mode_name"]:
# TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
# I doubt that this special case is actually required.
print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
"Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
if match["gamma"]:
gamma = match["gamma"].strip()
# xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
# Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
# so we approximate by 1e-10.
gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
options["gamma"] = gamma
if match["crtc"]:
options["crtc"] = match["crtc"]
if match["rate"]:
options["rate"] = match["rate"]
return XrandrOutput(match["output"], edid, options), modes
@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)
if line and line[0].startswith("#"):
continue
options[line[0]] = line[1] if len(line) > 1 else None
edid = None
if options["output"] in edid_map:
edid = edid_map[options["output"]]
else:
# This fuzzy matching is for legacy autorandr that used sysfs output names
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 in fuzzy_edid_map:
edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
elif "off" not in options:
raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
"is not off in config file." % (options["output"], options["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 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
if "*" in self.edid:
return match_asterisk(self.edid, other.edid) > 0
elif "*" in other.edid:
return match_asterisk(other.edid, self.edid) > 0
return self.edid == other.edid
def __ne__(self, other):
return not (self == other)
def __eq__(self, other):
return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
def verbose_diff(self, other):
"Compare to another XrandrOutput and return a list of human readable differences"
diffs = []
if not self.edid_equals(other):
diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
if self.output != other.output:
diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
if "off" in self.options and "off" not in other.options:
diffs.append("The output is disabled currently, but active in the new configuration")
elif "off" in other.options and "off" not in self.options:
diffs.append("The output is currently enabled, but inactive in the new configuration")
else:
for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
if name not in other.options:
diffs.append("Option --%s %sis not present in the new configuration" %
(name, "(= `%s') " % self.options[name] if self.options[name] else ""))
elif name not in self.options:
diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
(name, other.options[name]))
elif self.options[name] != other.options[name]:
diffs.append("Option --%s %sis `%s' in the new configuration" %
(name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
return diffs
def xrandr_version():
"Return the version of XRandR that this system uses"
if getattr(xrandr_version, "version", False) is False:
version_string = os.popen("xrandr -v").read()
try:
version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
xrandr_version.version = Version(version)
except AttributeError:
xrandr_version.version = Version("1.3.0")
return xrandr_version.version
def debug_regexp(pattern, string):
"Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
try:
import regex
bounds = (0, len(string))
while bounds[0] != bounds[1]:
half = int((bounds[0] + bounds[1]) / 2)
if half == bounds[0]:
break
bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
partial_length = bounds[0]
return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
(partial_length, string[max(0, partial_length - 20):partial_length],
string[partial_length:partial_length + 10]))
except ImportError:
pass
return "Debug information would be available if the `regex' module was installed."
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 AutorandrException("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)
if len(split_xrandr_output) < 2:
raise AutorandrException("No output boundaries found", report_bug=True)
outputs = OrderedDict()
modes = OrderedDict()
for i in range(1, len(split_xrandr_output), 2):
output_name = split_xrandr_output[i].split()[0]
output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
outputs[output_name] = output
if output_modes:
modes[output_name] = output_modes
# consider a closed lid as disconnected if other outputs are connected
if sum(o.edid != None for o in outputs.values()) > 1:
for output_name in outputs.keys():
if is_closed_lid(output_name):
outputs[output_name].edid = None
return outputs, modes
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.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
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)
for output_name in list(config.keys()):
if config[output_name].edid is None:
del config[output_name]
profiles[profile] = {
"config": config,
"path": os.path.join(profile_path, profile),
"config-mtime": os.stat(config_name).st_mtime,
}
return profiles
def get_symlinks(profile_path):
"Load all symlinks from a directory"
symlinks = {}
for link in os.listdir(profile_path):
file_name = os.path.join(profile_path, link)
if os.path.islink(file_name):
symlinks[link] = os.readlink(file_name)
return symlinks
def match_asterisk(pattern, data):
"""Match data against a pattern
The difference to fnmatch is that this function only accepts patterns with a single
asterisk and that it returns a "closeness" number, which is larger the better the match.
Zero indicates no match at all.
"""
if "*" not in pattern:
return 1 if pattern == data else 0
parts = pattern.split("*")
if len(parts) > 2:
raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
if not data.startswith(parts[0]):
return 0
if not data.endswith(parts[1]):
return 0
matched = len(pattern)
total = len(data) + 1
return matched * 1. / total
def find_profiles(current_config, profiles):
"Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
detected_profiles = []
for profile_name, profile in profiles.items():
config = profile["config"]
matches = True
for name, output in config.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 config.keys() for name in current_config.keys() if current_config[name].edid)):
continue
if matches:
closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(current_config[name].edid, output.edid))
detected_profiles.append((closeness, profile_name))
detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
return detected_profiles
def profile_blocked(profile_path, meta_information=None):
"""Check if a profile is blocked.
meta_information is expected to be an dictionary. It will be passed to the block scripts
in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
"""
return not exec_scripts(profile_path, "block", meta_information)
def check_configuration_pre_save(configuration):
"Check that a configuration is safe for saving."
outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
for output in outputs:
if "off" not in configuration[output].options and not configuration[output].edid:
return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
"This typically means that it has been recently unplugged and then not properly disabled\n"
"by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
"this command.") % {"o": output}
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())
for output in outputs:
if configuration[output].edid:
print(output, configuration[output].edid, file=setup)
def save_configuration(profile_path, profile_name, configuration, forced=False):
"Save a configuration into a profile"
if not os.path.isdir(profile_path):
os.makedirs(profile_path)
config_path = os.path.join(profile_path, "config")
setup_path = os.path.join(profile_path, "setup")
if os.path.isfile(config_path) and not forced:
raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
if os.path.isfile(setup_path) and not forced:
raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
with open(config_path, "w") as config:
output_configuration(configuration, config)
with open(setup_path, "w") as setup:
output_setup(configuration, setup)
def update_mtime(filename):
"Update a file's mtime"
try:
os.utime(filename, None)
return True
except:
return False
def call_and_retry(*args, **kwargs):
"""Wrapper around subprocess.call that retries failed calls.
This function calls subprocess.call and on non-zero exit states,
waits a second and then retries once. This mitigates #47,
a timing issue with some drivers.
"""
if "dry_run" in kwargs:
dry_run = kwargs["dry_run"]
del kwargs["dry_run"]
else:
dry_run = False
kwargs_redirected = dict(kwargs)
if not dry_run:
if hasattr(subprocess, "DEVNULL"):
kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
else:
kwargs_redirected["stdout"] = open(os.devnull, "w")
kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
retval = subprocess.call(*args, **kwargs_redirected)
if retval != 0:
time.sleep(1)
retval = subprocess.call(*args, **kwargs)
return retval
def get_fb_dimensions(configuration):
width = 0
height = 0
for output in configuration.values():
if "off" in output.options or not output.edid:
continue
# This won't work with all modes -- but it's a best effort.
match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
if not match:
return None
o_mode = match.group(0)
o_width, o_height = map(int, o_mode.split("x"))
if "transform" in output.options:
a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
w = (g * o_width + h * o_height + i)
x = (a * o_width + b * o_height + c) / w
y = (d * o_width + e * o_height + f) / w
o_width, o_height = x, y
if "rotate" in output.options:
if output.options["rotate"] in ("left", "right"):
o_width, o_height = o_height, o_width
if "pos" in output.options:
o_left, o_top = map(int, output.options["pos"].split("x"))
o_width += o_left
o_height += o_top
if "panning" in output.options:
match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
if match:
detail = match.groupdict(default="0")
o_width = int(detail.get("w")) + int(detail.get("x"))
o_height = int(detail.get("h")) + int(detail.get("y"))
width = max(width, o_width)
height = max(height, o_height)
return int(width), int(height)
def apply_configuration(new_configuration, current_configuration, dry_run=False):
"Apply a configuration"
found_top_left_monitor = False
found_left_monitor = False
found_top_monitor = False
outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
if dry_run:
base_argv = ["echo", "xrandr"]
else:
base_argv = ["xrandr"]
# There are several xrandr / driver bugs we need to take care of here:
# - We cannot enable more than two screens at the same time
# See https://github.com/phillipberndt/autorandr/pull/6
# and commits f4cce4d and 8429886.
# - We cannot disable all screens
# See https://github.com/phillipberndt/autorandr/pull/20
# - We should disable screens before enabling others, because there's
# a limit on the number of enabled screens
# - We must make sure that the screen at 0x0 is activated first,
# or the other (first) screen to be activated would be moved there.
# - If an active screen already has a transformation and remains active,
# the xrandr call fails with an invalid RRSetScreenSize parameter error.
# Update the configuration in 3 passes in that case. (On Haswell graphics,
# at least.)
# - Some implementations can not handle --transform at all, so avoid it unless
# necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
# - Some implementations can not handle --panning without specifying --fb
# explicitly, so avoid it unless necessary.
# (See https://github.com/phillipberndt/autorandr/issues/72)
fb_dimensions = get_fb_dimensions(new_configuration)
try:
base_argv += ["--fb", "%dx%d" % fb_dimensions]
except:
# Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
pass
auxiliary_changes_pre = []
disable_outputs = []
enable_outputs = []
remain_active_count = 0
for output in outputs:
if not new_configuration[output].edid or "off" in new_configuration[output].options:
disable_outputs.append(new_configuration[output].option_vector)
else:
if output not in current_configuration:
raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
"Don't know how to proceed." % output)
if "off" not in current_configuration[output].options:
remain_active_count += 1
option_vector = new_configuration[output].option_vector
if xrandr_version() >= Version("1.3.0"):
for option, off_value in (("transform", "none"), ("panning", "0x0")):
if option in current_configuration[output].options:
auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
else:
try:
option_index = option_vector.index("--%s" % option)
if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
except ValueError:
pass
if not found_top_left_monitor:
position = new_configuration[output].options.get("pos", "0x0")
if position == "0x0":
found_top_left_monitor = True
enable_outputs.insert(0, option_vector)
elif not found_left_monitor and position.startswith("0x"):
found_left_monitor = True
enable_outputs.insert(0, option_vector)
elif not found_top_monitor and position.endswith("x0"):
found_top_monitor = True
enable_outputs.insert(0, option_vector)
else:
enable_outputs.append(option_vector)
else:
enable_outputs.append(option_vector)
# Perform pe-change auxiliary changes
if auxiliary_changes_pre:
argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
if call_and_retry(argv, dry_run=dry_run) != 0:
raise AutorandrException("Command failed: %s" % " ".join(argv))
# Disable unused outputs, but make sure that there always is at least one active screen
disable_keep = 0 if remain_active_count else 1
if len(disable_outputs) > disable_keep:
argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
if call_and_retry(argv, dry_run=dry_run) != 0:
# Disabling the outputs failed. Retry with the next command:
# Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
# This does not occur if simultaneously the primary screen is reset.
pass
else:
disable_outputs = disable_outputs[-1:] if disable_keep else []
# If disable_outputs still has more than one output in it, one of the xrandr-calls below would
# disable the last two screens. This is a problem, so if this would happen, instead disable only
# one screen in the first call below.
if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
# In the context of a xrandr call that changes the display state, `--query' should do nothing
disable_outputs.insert(0, ['--query'])
# If we did not find a candidate, we might need to inject a call
# If there is no output to disable, we will enable 0x and x0 at the same time
if not found_top_left_monitor and len(disable_outputs) > 0:
# If the call to 0x and x0 is splitted, inject one of them
if found_top_monitor and found_left_monitor:
enable_outputs.insert(0, enable_outputs[0])
# Enable the remaining outputs in pairs of two operations
operations = disable_outputs + enable_outputs
for index in range(0, len(operations), 2):
argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
if call_and_retry(argv, dry_run=dry_run) != 0:
raise AutorandrException("Command failed: %s" % " ".join(argv))
def is_equal_configuration(source_configuration, target_configuration):
"""
Check if all outputs from target are already configured correctly in source and
that no other outputs are active.
"""
for output in target_configuration.keys():
if "off" in target_configuration[output].options:
if (output in source_configuration and "off" not in source_configuration[output].options):
return False
else:
if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
return False
for output in source_configuration.keys():
if "off" in source_configuration[output].options:
if output in target_configuration and "off" not in target_configuration[output].options:
return False
else:
if output not in target_configuration:
return False
return True
def add_unused_outputs(source_configuration, target_configuration):
"Add outputs that are missing in target to target, in 'off' state"
for output_name, output in source_configuration.items():
if output_name not in target_configuration:
target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
def remove_irrelevant_outputs(source_configuration, target_configuration):
"Remove outputs from target that ought to be 'off' and already are"
for output_name, output in source_configuration.items():
if "off" in output.options:
if output_name in target_configuration:
if "off" in target_configuration[output_name].options:
del target_configuration[output_name]
def generate_virtual_profile(configuration, modes, profile_name):
"Generate one of the virtual profiles"
configuration = copy.deepcopy(configuration)
if profile_name == "common":
mode_sets = []
for output, output_modes in modes.items():
mode_set = set()
if configuration[output].edid:
for mode in output_modes:
mode_set.add((mode["width"], mode["height"]))
mode_sets.append(mode_set)
common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
if common_resolution:
for output in configuration:
configuration[output].options = {}
if output in modes and configuration[output].edid:
modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
mode = modes_filtered[0]
configuration[output].options["mode"] = mode['name']
configuration[output].options["pos"] = "0x0"
else:
configuration[output].options["off"] = None
elif profile_name in ("horizontal", "vertical"):
shift = 0
if profile_name == "horizontal":
shift_index = "width"
pos_specifier = "%sx0"
else:
shift_index = "height"
pos_specifier = "0x%s"
for output in configuration:
configuration[output].options = {}
if output in modes and configuration[output].edid:
def key(a):
score = int(a["width"]) * int(a["height"])
if a["preferred"]:
score += 10**6
return score
output_modes = sorted(modes[output], key=key)
mode = output_modes[-1]
configuration[output].options["mode"] = mode["name"]
configuration[output].options["rate"] = mode["rate"]
configuration[output].options["pos"] = pos_specifier % shift
shift += int(mode[shift_index])
else:
configuration[output].options["off"] = None
elif profile_name == "clone-largest":
modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
biggest_resolution = modes_sorted[0]
for output in configuration:
configuration[output].options = {}
if output in modes and configuration[output].edid:
def key(a):
score = int(a["width"]) * int(a["height"])
if a["preferred"]:
score += 10**6
return score
output_modes = sorted(modes[output], key=key)
mode = output_modes[-1]
configuration[output].options["mode"] = mode["name"]
configuration[output].options["rate"] = mode["rate"]
configuration[output].options["pos"] = "0x0"
scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
float(biggest_resolution["height"]) / float(mode["height"]))
mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
else:
configuration[output].options["off"] = None
elif profile_name == "off":
for output in configuration:
for key in list(configuration[output].options.keys()):
del configuration[output].options[key]
configuration[output].options["off"] = None
return configuration
def print_profile_differences(one, another):
"Print the differences between two profiles for debugging"
if one == another:
return
print("| Differences between the two profiles:")
for output in set(chain.from_iterable((one.keys(), another.keys()))):
if output not in one:
if "off" not in another[output].options:
print("| Output `%s' is missing from the active configuration" % output)
elif output not in another:
if "off" not in one[output].options:
print("| Output `%s' is missing from the new configuration" % output)
else:
for line in one[output].verbose_diff(another[output]):
print("| [Output %s] %s" % (output, line))
print("\\-")
def exit_help():
"Print help and exit"
print(help_text)
for profile in virtual_profiles: