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.
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:
29 yield line.decode("utf-8")
31 raise Exception("Error executing "+str(args))
32 def processOutputIt(*args):
33 return list(processOutputGen(*args)) # list() iterates over the generator
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:
45 class RelativeScreenPosition(Enum):
46 '''Represents the relative position of the external screen to the internal one'''
52 def __init__(self, text):
55 self._value_ = len(cls.__members__) + 1
61 '''Represents a resolution of a screen'''
62 def __init__(self, width, height):
67 def fromDatabase(cls, dbstr):
70 parts = dbstr.split("x")
72 raise ValueError(xrandrstr)
73 return Resolution(*map(int,parts))
75 def forDatabase(self):
76 return str(self.width)+'x'+str(self.height)
79 return self.forDatabase()
82 return (self.width, self.height)
84 def __eq__(self, other):
85 if not isinstance(other, Resolution):
87 return self.width == other.width and self.height == other.height
89 def __ne__(self, other):
90 return not self.__eq__(other)
93 return hash(("Resolution",self.width,self.height))
97 ratio = int(round(16.0*self.height/self.width))
98 if ratio == 11: # 16:10.66 = 3:2
100 elif ratio == 12: # 16:12 = 4:3
102 elif ratio == 13: # 16:12.8 = 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)
109 return 'screen.Resolution('+self.forXrandr()+')'
111 def pixelCount(self):
112 return self.width * self.height
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)
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
128 def getInternalArgs(self):
129 if self.intResolution is None:
131 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
132 if not self.extIsPrimary:
133 args.append('--primary')
136 def getExternalArgs(self, intName):
137 if self.extResolution is None:
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:
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]
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))
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
171 return str(self.name)
174 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
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
180 def addResolution(self, resolution):
181 assert isinstance(resolution, Resolution)
182 self._resolutions.add(resolution)
184 def appendToEdid(self, s):
185 if self.edid is None:
190 def setPreferredResolution(self, resolution):
191 assert isinstance(resolution, Resolution) and resolution in self._resolutions
192 self._preferredResolution = resolution
194 def getPreferredResolution(self):
195 if self._preferredResolution is not None:
196 return self._preferredResolution
197 return self.getResolutionList()[0] # prefer the largest resolution
199 def getResolutionList(self):
200 return sorted(self._resolutions, key=lambda r: -r.pixelCount())
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
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?
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)
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
233 if xrandrSource is None:
234 xrandrSource = processOutputGen("xrandr", "-q", "--verbose")
235 for line in xrandrSource:
237 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
239 connector.appendToEdid(m.group(1))
243 # fallthrough to the rest of the loop for parsing of this line
245 m = re.search(r'^Screen [0-9]+: ', line)
246 if m is not None: # ignore this line
250 m = re.search(r'^([\w\-]+) (dis)?connected ', line)
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)
259 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
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)
268 m = re.search(r'^\s*EDID:\s*$', 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
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):
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())
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)
299 assert setup.extResolution is None, "There's no external screen to set a resolution for"
300 # now compose the arguments
302 for name in connectorArgs:
303 call += ["--output", name] + connectorArgs[name]
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
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
316 def putDBInfo(self, db, setup):
317 if not self.externalConnector or not self.externalConnector.edid:
319 db.putConfig(self.externalConnector.edid, setup)