160efecaa6d95b2915657ad0d81b6abb646701a2
[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__) + 1
48         self.text = text
49     def __str__(self):
50         return self.text
51
52 class Resolution:
53     '''Represents a resolution of a screen'''
54     def __init__(self, width, height):
55         self.width = width
56         self.height = height
57     
58     @classmethod
59     def fromDatabase(cls, dbstr):
60         if dbstr is None:
61             return None
62         parts = dbstr.split("x")
63         if len(parts) != 2:
64             raise ValueError(xrandrstr)
65         return Resolution(*map(int,parts))
66     
67     def forDatabase(self):
68         return str(self.width)+'x'+str(self.height)
69     
70     def forXrandr(self):
71         return self.forDatabase()
72     
73     def toTuple(self):
74         return (self.width, self.height)
75     
76     def __eq__(self, other):
77         if not isinstance(other, Resolution):
78             return False
79         return self.width == other.width and self.height == other.height
80     
81     def __ne__(self, other):
82         return not self.__eq__(other)
83     
84     def __hash__(self):
85         return hash(("Resolution",self.width,self.height))
86     
87     def __str__(self):
88         # get ratio
89         ratio = int(round(16.0*self.height/self.width))
90         if ratio == 11: # 16:10.66 = 3:2
91             strRatio = "3:2"
92         elif ratio == 12: # 16:12 = 4:3
93             strRatio = '4:3'
94         elif ratio == 13: # 16:12.8 = 5:4
95             strRatio = '5:4'
96         else: # let's just hope this will never be 14 or more...
97             strRatio = '16:%d' % ratio
98         return '%dx%d (%s)' %(self.width, self.height, strRatio)
99     
100     def __repr__(self):
101         return 'screen.Resolution('+self.forXrandr()+')'
102     
103     def pixelCount(self):
104         return self.width * self.height
105
106
107 class ScreenSetup:
108     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
109        are they positioned, which is the primary screen.'''
110     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
111         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
112         assert intResolution is None or isinstance(intResolution, Resolution)
113         assert extResolution is None or isinstance(extResolution, Resolution)
114         
115         self.intResolution = intResolution
116         self.extResolution = extResolution
117         self.relPosition = relPosition
118         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
119     
120     def getInternalArgs(self):
121         if self.intResolution is None:
122             return ["--off"]
123         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
124         if not self.extIsPrimary:
125             args.append('--primary')
126         return args
127     
128     def getExternalArgs(self, intName):
129         if self.extResolution is None:
130             return ["--off"]
131         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
132         if self.extIsPrimary:
133             args.append('--primary')
134         if self.intResolution is None:
135             return args
136         # set position
137         args += [{
138                 RelativeScreenPosition.LEFT  : '--left-of',
139                 RelativeScreenPosition.RIGHT : '--right-of',
140                 RelativeScreenPosition.ABOVE : '--above',
141                 RelativeScreenPosition.BELOW : '--below',
142                 RelativeScreenPosition.MIRROR: '--same-as',
143             }[self.relPosition], intName]
144         return args
145     
146     def __str__(self):
147         if self.intResolution is None:
148             return "External display only, at "+str(self.extResolution)
149         if self.extResolution is None:
150             return "Internal display only, at "+str(self.intResolution)
151         return "External display %s at %s %s internal display %s at %s" % ("(primary)" if self.extIsPrimary else "", str(self.extResolution), str(self.relPosition), "" if self.extIsPrimary else "(primary)", str(self.intResolution))
152
153 class Connector:
154     def __init__(self, name=None):
155         self.name = name # connector name, e.g. "HDMI1"
156         self.edid = None # EDID string for the connector, or None if disconnected
157         self._resolutions = set() # set of Resolution objects, empty if disconnected
158         self._preferredResolution = None
159         self.previousResolution = None
160         self.hasLastResolution = False
161     
162     def __str__(self):
163         return str(self.name)
164     
165     def __repr__(self):
166         return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
167     
168     def isConnected(self):
169         assert (self.edid is None) == (len(self._resolutions)==0), "Resolution-EDID mismatch; #resolutions: {}".format(len(self._resolutions))
170         return self.edid is not None
171     
172     def addResolution(self, resolution):
173         assert isinstance(resolution, Resolution)
174         self._resolutions.add(resolution)
175     
176     def appendToEdid(self, s):
177         if self.edid is None:
178             self.edid = s
179         else:
180             self.edid += s
181     
182     def setPreferredResolution(self, resolution):
183         assert isinstance(resolution, Resolution) and resolution in self._resolutions
184         self._preferredResolution = resolution
185     
186     def getPreferredResolution(self):
187         if self._preferredResolution is not None:
188             return self._preferredResolution
189         return self.getResolutionList()[0] # prefer the largest resolution
190     
191     def getResolutionList(self):
192         return sorted(self._resolutions, key=lambda r: -r.pixelCount())
193
194 class ScreenSituation:
195     connectors = [] # contains all the Connector objects
196     internalConnector = None # the internal Connector object (will be an enabled one)
197     externalConnector = None # the used external Connector object (an enabled one), or None
198     previousSetup = None # None or the ScreenSetup used the last time this external screen was connected
199     
200     '''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'''
201     def __init__(self, internalConnectorNames, externalConnectorNames = None):
202         '''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
203            just choose any remaining connector.'''
204         # which connectors are there?
205         self._getXrandrInformation()
206         # figure out which is the internal connector
207         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
208         if self.internalConnector is None:
209             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
210         print("Detected internal connector:",self.internalConnector)
211         # and the external one
212         if externalConnectorNames is None:
213             externalConnectorNames = map(lambda c: c.name, self.connectors)
214             externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
215         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
216         if self.internalConnector == self.externalConnector:
217             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
218         print("Detected external connector:",self.externalConnector)
219     
220     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
221     def _getXrandrInformation(self):
222         connector = None # current connector
223         readingEdid = False
224         for line in processOutputGen("xrandr", "-q", "--verbose"):
225             if readingEdid:
226                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
227                 if m is not None:
228                     connector.appendToEdid(m.group(1))
229                     continue
230                 else:
231                     readingEdid = False
232                     # fallthrough to the rest of the loop for parsing of this line
233             # screen?
234             m = re.search(r'^Screen [0-9]+: ', line)
235             if m is not None: # ignore this line
236                 connector = None
237                 continue
238             # new connector?
239             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
240             if m is not None:
241                 connector = Connector(m.group(1))
242                 assert not any(c.name == connector.name for c in self.connectors)
243                 if not connector.name.startswith("VIRTUAL"):
244                     # skip "VIRTUAL" connectors
245                     self.connectors.append(connector)
246                 continue
247             # new resolution?
248             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
249             if m is not None:
250                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
251                 assert connector is not None
252                 connector.addResolution(resolution)
253                 if re.search(r' [+]preferred\b', line):
254                     connector.setPreferredResolution(resolution)
255                 continue
256             # EDID?
257             m = re.search(r'^\s*EDID:\s*$', line)
258             if m is not None:
259                 readingEdid = True
260                 continue
261             # unknown line
262             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected; --verbose adds a whole lot of other weird stuff
263     
264     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
265     def _findAvailableConnector(self, tryConnectorNames):
266         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
267             return c
268         return None
269     
270     # return resolutions available for both internal and external screen
271     def commonResolutions(self):
272         assert self.externalConnector is not None, "Common resolutions may only be queried if there is an external screen connected."
273         internalRes = self.internalConnector.getResolutionList()
274         externalRes = self.externalConnector.getResolutionList()
275         return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount())
276     
277     # compute the xrandr call
278     def forXrandr(self, setup):
279         # turn all screens off
280         connectorArgs = {} # maps connector names to xrand arguments
281         for c in self.connectors:
282             connectorArgs[c.name] = ["--off"]
283         # set arguments for the relevant ones
284         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
285         if self.externalConnector is not None:
286             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
287         else:
288             assert setup.extResolution is None, "There's no external screen to set a resolution for"
289         # now compose the arguments
290         call = ["xrandr"]
291         for name in connectorArgs:
292             call += ["--output", name] + connectorArgs[name]
293         return call
294
295     def fetchDBInfo(self, db):
296         if self.externalConnector and self.externalConnector.edid:
297             self.previousSetup = db.getConfig(self.externalConnector.edid) # may also return None
298         else:
299             self.previousSetup = None
300         if self.previousSetup:
301             print("Known screen, previous setup:", self.previousSetup)
302             self.externalConnector.previousResolution = self.previousSetup.extResolution
303             self.internalConnector.previousResolution = self.previousSetup.intResolution
304     
305     def putDBInfo(self, db, setup):
306         if not self.externalConnector or not self.externalConnector.edid:
307             return
308         db.putConfig(self.externalConnector.edid, setup)