39c9367d6b61291bfab410c9318755f194ccf241
[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 from fractions import Fraction
22
23 ## utility functions
24
25 # execute a process, return output as iterator, throw exception if there was an error
26 # you *must* iterate to the end if you use this!
27 def processOutputGen(*args):
28     with subprocess.Popen(args, stdout=subprocess.PIPE) as p:
29         for line in p.stdout:
30             yield line.decode("utf-8")
31     if p.returncode != 0:
32         raise Exception("Error executing "+str(args))
33 def processOutputIt(*args):
34     return list(processOutputGen(*args)) # list() iterates over the generator
35
36 ## the classes
37
38 class RelativeScreenPosition(Enum):
39     '''Represents the relative position of the external screen to the internal one'''
40     LEFT      = ("left of")
41     RIGHT     = ("right of")
42     ABOVE     = ("above")
43     BELOW     = ("below")
44     MIRROR    = ("same as")
45     def __init__(self, text):
46         # auto numbering
47         cls = self.__class__
48         self._value_ = len(cls.__members__) + 1
49         self.text = text
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     @classmethod
58     def fromDatabase(cls, dbstr):
59         if dbstr is None:
60             return None
61         parts = dbstr.split("x")
62         if len(parts) != 2:
63             raise ValueError(xrandrstr)
64         return Resolution(*map(int,parts))
65     
66     def forDatabase(self):
67         return str(self.width)+'x'+str(self.height)
68     
69     def forXrandr(self):
70         return self.forDatabase()
71     
72     def toTuple(self):
73         return (self.width, self.height)
74     
75     def __eq__(self, other):
76         if not isinstance(other, Resolution):
77             return False
78         return self.width == other.width and self.height == other.height
79     
80     def __ne__(self, other):
81         return not self.__eq__(other)
82     
83     def __hash__(self):
84         return hash(("Resolution",self.width,self.height))
85     
86     def __str__(self):
87         # get ratio
88         ratio = Fraction(self.width, self.height) # automatically divides by the gcd
89         strRatio = "%d:%d" % (ratio.numerator, ratio.denominator)
90         return '%dx%d (%s)' %(self.width, self.height, strRatio)
91     
92     def __repr__(self):
93         return 'screen.Resolution('+self.forXrandr()+')'
94     
95     def pixelCount(self):
96         return self.width * self.height
97
98
99 class ScreenSetup:
100     '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
101        are they positioned, which is the primary screen.'''
102     def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
103         '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
104         assert intResolution is None or isinstance(intResolution, Resolution)
105         assert extResolution is None or isinstance(extResolution, Resolution)
106         
107         self.intResolution = intResolution
108         self.extResolution = extResolution
109         self.relPosition = relPosition
110         self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
111     
112     def getInternalArgs(self):
113         if self.intResolution is None:
114             return ["--off"]
115         args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
116         if not self.extIsPrimary:
117             args.append('--primary')
118         return args
119     
120     def getExternalArgs(self, intName):
121         if self.extResolution is None:
122             return ["--off"]
123         args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
124         if self.extIsPrimary:
125             args.append('--primary')
126         if self.intResolution is None:
127             return args
128         # set position
129         args += [{
130                 RelativeScreenPosition.LEFT  : '--left-of',
131                 RelativeScreenPosition.RIGHT : '--right-of',
132                 RelativeScreenPosition.ABOVE : '--above',
133                 RelativeScreenPosition.BELOW : '--below',
134                 RelativeScreenPosition.MIRROR: '--same-as',
135             }[self.relPosition], intName]
136         return args
137
138 class Connector:
139     def __init__(self, name=None):
140         self.name = name # connector name, e.g. "HDMI1"
141         self.edid = None # EDID string for the connector, or None if disconnected
142         self._resolutions = set() # list of Resolution objects, empty if disconnected
143         self.preferredResolution = None
144         self.__lastResolution = None
145         self.hasLastResolution = False
146     
147     def __str__(self):
148         return str(self.name)
149     
150     def __repr__(self):
151         return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
152     
153     def __setLastRes(self, res):
154         # res == None means this display was last switched off
155         if res is not None and not res in self._resolutions:
156             raise ValueError("Resolution "+res+" not available for "+self.name+".")
157         self.__lastResolution = res
158         self.hasLastResolution = True
159     
160     def __getLastRes(self):
161         if not self.hasLastResolution:
162             raise ValueError("Connector %s has no last known resolution." % self.name)
163         return self.__lastResolution
164     
165     lastResolution = property(__getLastRes, __setLastRes)
166     
167     def isConnected(self):
168         assert (self.edid is None) == (len(self._resolutions)==0)
169         return self.edid is not None
170     
171     def addResolution(self, resolution):
172         assert isinstance(resolution, Resolution)
173         self._resolutions.add(resolution)
174     
175     def appendToEdid(self, s):
176         if self.edid is None:
177             self.edid = s
178         else:
179             self.edid += s
180     
181     def getResolutionList(self):
182         return sorted(self._resolutions, key=lambda r: (0 if self.hasLastResolution and r==self.lastResolution else 1, 0 if r==self.preferredResolution else 1, -r.pixelCount()))
183
184 class ScreenSituation:
185     connectors = [] # contains all the Connector objects
186     internalConnector = None # the internal Connector object (will be an enabled one)
187     externalConnector = None # the used external Connector object (an enabled one), or None
188     
189     '''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'''
190     def __init__(self, internalConnectorNames, externalConnectorNames = None):
191         '''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
192            just choose any remaining connector.'''
193         # which connectors are there?
194         self._getXrandrInformation()
195         for c in self.connectors:
196             print(repr(c))
197             print()
198         # figure out which is the internal connector
199         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
200         if self.internalConnector is None:
201             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
202         print("Detected internal connector:",self.internalConnector)
203         # and the external one
204         if externalConnectorNames is None:
205             externalConnectorNames = map(lambda c: c.name, self.connectors)
206             externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
207         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
208         if self.internalConnector == self.externalConnector:
209             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
210         print("Detected external connector:",self.externalConnector)
211         # self.preferredSetup is left uninitialized so you can't access it before trying a lookup in the database
212     
213     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
214     def _getXrandrInformation(self):
215         connector = None # current connector
216         readingEdid = False
217         for line in processOutputGen("xrandr", "-q", "--verbose"):
218             if readingEdid:
219                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
220                 if m is not None:
221                     connector.appendToEdid(m.group(1))
222                     continue
223                 else:
224                     readingEdid = False
225                     # fallthrough to the rest of the loop for parsing of this line
226             # screen?
227             m = re.search(r'^Screen [0-9]+: ', line)
228             if m is not None: # ignore this line
229                 connector = None
230                 continue
231             # new connector?
232             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
233             if m is not None:
234                 connector = Connector(m.group(1))
235                 assert not any(c.name == connector.name for c in self.connectors)
236                 self.connectors.append(connector)
237                 continue
238             # new resolution?
239             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
240             if m is not None:
241                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
242                 assert connector is not None
243                 connector.addResolution(resolution)
244                 if '+preferred' in line:
245                     connector.preferredResolution = resolution
246                 continue
247             # EDID?
248             m = re.search(r'^\s*EDID:\s*$', line)
249             if m is not None:
250                 readingEdid = True
251                 continue
252             # unknown line
253             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
254             #print("Warning: Unknown xrandr line %s" % line)
255     
256     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
257     def _findAvailableConnector(self, tryConnectorNames):
258         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
259             return c
260         return None
261     
262     # return available internal resolutions
263     def internalResolutions(self):
264         return self.internalConnector.getResolutionList()
265     
266     # return available external resolutions (or None, if there is no external screen connected)
267     def externalResolutions(self):
268         if self.externalConnector is None:
269             return None
270         return self.externalConnector.getResolutionList()
271     
272     # return resolutions available for both internal and external screen
273     def commonResolutions(self):
274         internalRes = self.internalResolutions()
275         externalRes = self.externalResolutions()
276         assert externalRes is not None
277         return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount())
278     
279     # compute the xrandr call
280     def forXrandr(self, setup):
281         # turn all screens off
282         connectorArgs = {} # maps connector names to xrand arguments
283         for c in self.connectors:
284             connectorArgs[c.name] = ["--off"]
285         # set arguments for the relevant ones
286         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
287         if self.externalConnector is not None:
288             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
289         else:
290             assert setup.extResolution is None, "There's no external screen to set a resolution for"
291         # now compose the arguments
292         call = ["xrandr"]
293         for name in connectorArgs:
294             call += ["--output", name] + connectorArgs[name]
295         return call
296
297     def fetchDBInfo(self, db):
298         if self.externalConnector and self.externalConnector.edid:
299             self.preferredSetup = db.getConfig(self.externalConnector.edid) # may also return None
300         else:
301             self.preferredSetup = None
302         if self.preferredSetup:
303             print("SETUP FOUND", self.preferredSetup)
304             self.externalConnector.lastResolution = self.preferredSetup.extResolution
305             self.internalConnector.lastResolution = self.preferredSetup.intResolution
306         else:
307             print("NO SETUP FOUND")
308     
309     def putDBInfo(self, db, setup):
310         if not self.externalConnector or not self.externalConnector.edid:
311             return
312         db.putConfig(self.externalConnector.edid, setup)