2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
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.
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.
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.
21 from fractions import Fraction
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:
30 yield line.decode("utf-8")
32 raise Exception("Error executing "+str(args))
33 def processOutputIt(*args):
34 return list(processOutputGen(*args)) # list() iterates over the generator
38 class RelativeScreenPosition(Enum):
39 '''Represents the relative position of the external screen to the internal one'''
45 def __init__(self, text):
48 self._value_ = len(cls.__members__) + 1
52 '''Represents a resolution of a screen'''
53 def __init__(self, width, height):
58 def fromDatabase(cls, dbstr):
61 parts = dbstr.split("x")
63 raise ValueError(xrandrstr)
64 return Resolution(*map(int,parts))
66 def forDatabase(self):
67 return str(self.width)+'x'+str(self.height)
70 return self.forDatabase()
73 return (self.width, self.height)
75 def __eq__(self, other):
76 if not isinstance(other, Resolution):
78 return self.width == other.width and self.height == other.height
80 def __ne__(self, other):
81 return not self.__eq__(other)
84 return hash(("Resolution",self.width,self.height))
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)
93 return 'screen.Resolution('+self.forXrandr()+')'
96 return self.width * self.height
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)
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
112 def getInternalArgs(self):
113 if self.intResolution is None:
115 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
116 if not self.extIsPrimary:
117 args.append('--primary')
120 def getExternalArgs(self, intName):
121 if self.extResolution is None:
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:
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]
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
148 return str(self.name)
151 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
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
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
165 lastResolution = property(__getLastRes, __setLastRes)
167 def isConnected(self):
168 assert (self.edid is None) == (len(self._resolutions)==0)
169 return self.edid is not None
171 def addResolution(self, resolution):
172 assert isinstance(resolution, Resolution)
173 self._resolutions.add(resolution)
175 def appendToEdid(self, s):
176 if self.edid is None:
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()))
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
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:
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
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
217 for line in processOutputGen("xrandr", "-q", "--verbose"):
219 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
221 connector.appendToEdid(m.group(1))
225 # fallthrough to the rest of the loop for parsing of this line
227 m = re.search(r'^Screen [0-9]+: ', line)
228 if m is not None: # ignore this line
232 m = re.search(r'^([\w\-]+) (dis)?connected ', line)
234 connector = Connector(m.group(1))
235 assert not any(c.name == connector.name for c in self.connectors)
236 self.connectors.append(connector)
239 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
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
248 m = re.search(r'^\s*EDID:\s*$', 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)
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):
262 # return available internal resolutions
263 def internalResolutions(self):
264 return self.internalConnector.getResolutionList()
266 # return available external resolutions (or None, if there is no external screen connected)
267 def externalResolutions(self):
268 if self.externalConnector is None:
270 return self.externalConnector.getResolutionList()
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())
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)
290 assert setup.extResolution is None, "There's no external screen to set a resolution for"
291 # now compose the arguments
293 for name in connectorArgs:
294 call += ["--output", name] + connectorArgs[name]
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
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
307 print("NO SETUP FOUND")
309 def putDBInfo(self, db, setup):
310 if not self.externalConnector or not self.externalConnector.edid:
312 db.putConfig(self.externalConnector.edid, setup)