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