6ba9418ae39058429b092aa4e1a646d91582e489
[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() # list of Resolution objects, empty if disconnected
156         self.preferredResolution = None
157         self.__lastResolution = 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 __setLastRes(self, res):
167         # res == None means this display was last switched off
168         if res is not None and not res in self._resolutions:
169             raise ValueError("Resolution "+res+" not available for "+self.name+".")
170         self.__lastResolution = res
171         self.hasLastResolution = True
172     
173     def __getLastRes(self):
174         if not self.hasLastResolution:
175             raise ValueError("Connector %s has no last known resolution." % self.name)
176         return self.__lastResolution
177     
178     lastResolution = property(__getLastRes, __setLastRes)
179     
180     def isConnected(self):
181         assert (self.edid is None) == (len(self._resolutions)==0)
182         return self.edid is not None
183     
184     def addResolution(self, resolution):
185         assert isinstance(resolution, Resolution)
186         self._resolutions.add(resolution)
187     
188     def appendToEdid(self, s):
189         if self.edid is None:
190             self.edid = s
191         else:
192             self.edid += s
193     
194     def getResolutionList(self):
195         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()))
196
197 class ScreenSituation:
198     connectors = [] # contains all the Connector objects
199     internalConnector = None # the internal Connector object (will be an enabled one)
200     externalConnector = None # the used external Connector object (an enabled one), or None
201     
202     '''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'''
203     def __init__(self, internalConnectorNames, externalConnectorNames = None):
204         '''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
205            just choose any remaining connector.'''
206         # which connectors are there?
207         self._getXrandrInformation()
208         for c in self.connectors:
209             print(repr(c))
210             print()
211         # figure out which is the internal connector
212         self.internalConnector = self._findAvailableConnector(internalConnectorNames)
213         if self.internalConnector is None:
214             raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
215         print("Detected internal connector:",self.internalConnector)
216         # and the external one
217         if externalConnectorNames is None:
218             externalConnectorNames = map(lambda c: c.name, self.connectors)
219             externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
220         self.externalConnector = self._findAvailableConnector(externalConnectorNames)
221         if self.internalConnector == self.externalConnector:
222             raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
223         print("Detected external connector:",self.externalConnector)
224         # self.lastSetup is left uninitialized so you can't access it before trying a lookup in the database
225     
226     # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
227     def _getXrandrInformation(self):
228         connector = None # current connector
229         readingEdid = False
230         for line in processOutputGen("xrandr", "-q", "--verbose"):
231             if readingEdid:
232                 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
233                 if m is not None:
234                     connector.appendToEdid(m.group(1))
235                     continue
236                 else:
237                     readingEdid = False
238                     # fallthrough to the rest of the loop for parsing of this line
239             # screen?
240             m = re.search(r'^Screen [0-9]+: ', line)
241             if m is not None: # ignore this line
242                 connector = None
243                 continue
244             # new connector?
245             m = re.search(r'^([\w\-]+) (dis)?connected ', line)
246             if m is not None:
247                 connector = Connector(m.group(1))
248                 assert not any(c.name == connector.name for c in self.connectors)
249                 self.connectors.append(connector)
250                 continue
251             # new resolution?
252             m = re.search(r'^\s*([\d]+)x([\d]+)', line)
253             if m is not None:
254                 resolution = Resolution(int(m.group(1)), int(m.group(2)))
255                 assert connector is not None
256                 connector.addResolution(resolution)
257                 if '+preferred' in line:
258                     connector.preferredResolution = resolution
259                 continue
260             # EDID?
261             m = re.search(r'^\s*EDID:\s*$', line)
262             if m is not None:
263                 readingEdid = True
264                 continue
265             # unknown line
266             # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
267             #print("Warning: Unknown xrandr line %s" % line)
268     
269     # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
270     def _findAvailableConnector(self, tryConnectorNames):
271         for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
272             return c
273         return None
274     
275     # return available internal resolutions
276     def internalResolutions(self):
277         return self.internalConnector.getResolutionList()
278     
279     # return available external resolutions (or None, if there is no external screen connected)
280     def externalResolutions(self):
281         if self.externalConnector is None:
282             return None
283         return self.externalConnector.getResolutionList()
284     
285     # return resolutions available for both internal and external screen
286     def commonResolutions(self):
287         internalRes = self.internalResolutions()
288         externalRes = self.externalResolutions()
289         assert externalRes is not None
290         return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount())
291     
292     # compute the xrandr call
293     def forXrandr(self, setup):
294         # turn all screens off
295         connectorArgs = {} # maps connector names to xrand arguments
296         for c in self.connectors:
297             connectorArgs[c.name] = ["--off"]
298         # set arguments for the relevant ones
299         connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
300         if self.externalConnector is not None:
301             connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
302         else:
303             assert setup.extResolution is None, "There's no external screen to set a resolution for"
304         # now compose the arguments
305         call = ["xrandr"]
306         for name in connectorArgs:
307             call += ["--output", name] + connectorArgs[name]
308         return call
309
310     def fetchDBInfo(self, db):
311         if self.externalConnector and self.externalConnector.edid:
312             self.lastSetup = db.getConfig(self.externalConnector.edid) # may also return None
313         else:
314             self.lastSetup = None
315         if self.lastSetup:
316             print("SETUP FOUND", self.lastSetup)
317             self.externalConnector.lastResolution = self.lastSetup.extResolution
318             self.internalConnector.lastResolution = self.lastSetup.intResolution
319         else:
320             print("NO SETUP FOUND")
321     
322     def putDBInfo(self, db, setup):
323         if not self.externalConnector or not self.externalConnector.edid:
324             return
325         db.putConfig(self.externalConnector.edid, setup)