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