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