reading EDIDs, everything works with only one display attached
[lilass.git] / screen.py
1 #!/usr/bin/python3
2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
19 import re, subprocess
20 from enum import Enum
21
22 ## utility functions
23
24 # execute a process, return output as iterator, throw exception if there was an error
25 # you *must* iterate to the end if you use this!
26 def processOutputGen(*args):
27     with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
28         for line in p.stdout:
29             yield line.decode("utf-8")
30     if p.returncode != 0:
31         raise Exception("Error executing "+str(args))
32 def processOutputIt(*args):
33     return list(processOutputGen(*args)) # list() iterates over the generator
34
35 ## the classes
36
37 class RelativeScreenPosition(Enum):
38     '''Represents the relative position of the external screen to the internal one'''
39     LEFT      = ("left of")
40     RIGHT     = ("right of")
41     ABOVE     = ("above")
42     BELOW     = ("below")
43     MIRROR    = ("same as")
44     def __init__(self, text):
45         # auto numbering
46         cls = self.__class__
47         self._value_ = len(cls.__members__)
48         self.text = text
49
50
51 class Resolution:
52     '''Represents a resolution of a screen'''
53     def __init__(self, width, height):
54         self.width = width
55         self.height = height
56     
57     def __eq__(self, other):
58         if not isinstance(other, Resolution):
59             return False
60         return self.width == other.width and self.height == other.height
61     
62     def __ne__(self, other):
63         return not self.__eq__(other)
64     
65     def __str__(self):
66         # get ratio
67         ratio = int(round(16.0*self.height/self.width))
68         if ratio == 12: # 16:12 = 4:3
69             strRatio = '4:3'
70         elif ratio == 13: # 16:12.8 = 5:4
71             strRatio = '5:4'
72         else: # let's just hope this will never be 14 or more...
73             strRatio = '16:%d' % ratio
74         return '%dx%d (%s)' %(self.width, self.height, strRatio)
75     
76     def __repr__(self):
77         return 'screen.Resolution('+self.forXrandr()+')'
78     
79     def forXrandr(self):
80         return str(self.width)+'x'+str(self.height)
81
82
83 class ScreenSetup:
84     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
85        are they positioned, which is the primary screen.'''
86     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
87         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
88         assert intResolution is None or isinstance(intResolution, Resolution)
89         assert extResolution is None or isinstance(extResolution, Resolution)
90         
91         self.intResolution = intResolution
92         self.extResolution = extResolution
93         self.relPosition = relPosition
94         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
95     
96     def getInternalArgs(self):
97         if self.intResolution is None:
98             return ["--off"]
99         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
100         if not self.extIsPrimary:
101             args.append('--primary')
102         return args
103     
104     def getExternalArgs(self, intName):
105         if self.extResolution is None:
106             return ["--off"]
107         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
108         if self.extIsPrimary:
109             args.append('--primary')
110         if self.intResolution is None:
111             return args
112         # set position
113         args += [{
114                 RelativeScreenPosition.LEFT  : '--left-of',
115                 RelativeScreenPosition.RIGHT : '--right-of',
116                 RelativeScreenPosition.ABOVE : '--above',
117                 RelativeScreenPosition.BELOW : '--below',
118                 RelativeScreenPosition.MIRROR: '--same-as',
119             }[self.relPosition], intName]
120         return args
121
122 class Connector:
123     name = None # connector name, e.g. "HDMI1"
124     edid = None # EDID string for the connector, or None if disconnected
125     resolutions = [] # list of Resolution objects, empty if disconnected
126     
127     def __init__(self, name=None):
128         self.name = name
129     def __str__(self):
130         return str(self.name)
131     def isConnected(self):
132         assert (self.edid is None) == (len(self.resolutions)==0)
133         return self.edid is not None
134     def addResolution(self, resolution):
135         assert isinstance(resolution, Resolution)
136         self.resolutions.append(resolution)
137     def appendToEdid(self, s):
138         if self.edid is None:
139             self.edid = s
140         else:
141             self.edid += s
142
143 class ScreenSituation:
144     connectors = [] # contains all the Connector objects
145     internalConnector = None # the internal Connector object (will be an enabled one)
146     externalConnector = None # the used external Connector object (an enabled one), or None
147     
148     '''Represents the "screen situation" a machine can be in: Which connectors exist, which resolutions do they have, what are the names for the internal and external screen'''
149     def __init__(self, internalConnectorNames, externalConnectorNames = None):
150         '''Both arguments are lists of connector names. The first one which exists and has a screen attached is chosen for that class. <externalConnectorNames> can be None to
151            just choose any remaining connector.'''
152         # which connectors are there?
153         self._getXrandrInformation()
154         # figure out which is the internal connector
155         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
156         if self.internalConnector is None:
157             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
158         print("Detected internal connector:",self.internalConnector)
159         # and the external one
160         if externalConnectorNames is None:
161             externalConnectorNames = map(lambda c: c.name, self.connectors)
162             externalConnectorNames = filter(lambda name: name != self.internalConnector.name, externalConnectorNames)
163         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
164         if self.internalConnector == self.externalConnector:
165             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
166         print("Detected external connector:",self.externalConnector)
167     
168     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
169     def _getXrandrInformation(self):
170         connector = None # current connector
171         readingEdid = False
172         for line in processOutputGen("xrandr", "-q", "--verbose"):
173             if readingEdid:
174                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
175                 if m is not None:
176                     connector.appendToEdid(m.group(1))
177                     continue
178                 else:
179                     readingEdid = False
180                     # fallthrough to the rest of the loop for parsing of this line
181             # screen?
182             m = re.search(r'^Screen [0-9]+: ', line)
183             if m is not None: # ignore this line
184                 connector = None
185                 continue
186             # new connector?
187             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
188             if m is not None:
189                 connector = Connector(m.group(1))
190                 assert not any(c.name == connector.name for c in self.connectors)
191                 self.connectors.append(connector)
192                 continue
193             # new resolution?
194             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
195             if m is not None:
196                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
197                 assert connector is not None
198                 connector.addResolution(resolution)
199                 continue
200             # EDID?
201             m = re.search(r'^\s*EDID:\s*$', line)
202             if m is not None:
203                 readingEdid = True
204                 continue
205             # unknown line
206             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
207             print("Warning: Unknown xrandr line %s" % line)
208     
209     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
210     def _findAvailableConnector(self, tryConnectorNames):
211         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
212             return c
213         return None
214     
215     # return available internal resolutions
216     def internalResolutions(self):
217         return self.internalConnector.resolutions
218     
219     # return available external resolutions (or None, if there is no external screen connected)
220     def externalResolutions(self):
221         if self.externalConnector is None:
222             return None
223         return self.externalConnector.resolutions
224     
225     # return resolutions available for both internal and external screen
226     def commonResolutions(self):
227         internalRes = self.internalResolutions()
228         externalRes = self.externalResolutions()
229         assert externalRes is not None
230         return [res for res in externalRes if res in internalRes]
231     
232     # compute the xrandr call
233     def forXrandr(self, setup):
234         # turn all screens off
235         connectorArgs = {} # maps connector names to xrand arguments
236         for c in self.connectors:
237             connectorArgs[c.name] = ["--off"]
238         # set arguments for the relevant ones
239         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
240         if self.externalConnector is not None:
241             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
242         else:
243             assert setup.extResolution is None, "There's no external screen to set a resolution for"
244         # now compose the arguments
245         call = ["xrandr"]
246         for name in connectorArgs:
247             call += ["--output", name] + connectorArgs[name]
248         return call
249