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
37 class RelativeScreenPosition(Enum):
38 '''Represents the relative position of the external screen to the internal one'''
44 def __init__(self, text):
47 self._value_ = len(cls.__members__) + 1
53 '''Represents a resolution of a screen'''
54 def __init__(self, width, height):
59 def fromDatabase(cls, dbstr):
62 parts = dbstr.split("x")
64 raise ValueError(xrandrstr)
65 return Resolution(*map(int,parts))
67 def forDatabase(self):
68 return str(self.width)+'x'+str(self.height)
71 return self.forDatabase()
74 return (self.width, self.height)
76 def __eq__(self, other):
77 if not isinstance(other, Resolution):
79 return self.width == other.width and self.height == other.height
81 def __ne__(self, other):
82 return not self.__eq__(other)
85 return hash(("Resolution",self.width,self.height))
89 ratio = int(round(16.0*self.height/self.width))
90 if ratio == 11: # 16:10.66 = 3:2
92 elif ratio == 12: # 16:12 = 4:3
94 elif ratio == 13: # 16:12.8 = 5:4
96 else: # let's just hope this will never be 14 or more...
97 strRatio = '16:%d' % ratio
98 return '%dx%d (%s)' %(self.width, self.height, strRatio)
101 return 'screen.Resolution('+self.forXrandr()+')'
103 def pixelCount(self):
104 return self.width * self.height
108 '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
109 are they positioned, which is the primary screen.'''
110 def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
111 '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
112 assert intResolution is None or isinstance(intResolution, Resolution)
113 assert extResolution is None or isinstance(extResolution, Resolution)
115 self.intResolution = intResolution
116 self.extResolution = extResolution
117 self.relPosition = relPosition
118 self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
120 def getInternalArgs(self):
121 if self.intResolution is None:
123 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
124 if not self.extIsPrimary:
125 args.append('--primary')
128 def getExternalArgs(self, intName):
129 if self.extResolution is None:
131 args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
132 if self.extIsPrimary:
133 args.append('--primary')
134 if self.intResolution is None:
138 RelativeScreenPosition.LEFT : '--left-of',
139 RelativeScreenPosition.RIGHT : '--right-of',
140 RelativeScreenPosition.ABOVE : '--above',
141 RelativeScreenPosition.BELOW : '--below',
142 RelativeScreenPosition.MIRROR: '--same-as',
143 }[self.relPosition], intName]
147 if self.intResolution is None:
148 return "External display only, at "+str(self.extResolution)
149 if self.extResolution is None:
150 return "Internal display only, at "+str(self.intResolution)
151 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))
154 def __init__(self, name=None):
155 self.name = name # connector name, e.g. "HDMI1"
156 self.edid = None # EDID string for the connector, or None if disconnected
157 self._resolutions = set() # set of Resolution objects, empty if disconnected
158 self._preferredResolution = None
159 self.previousResolution = None
160 self.hasLastResolution = False
163 return str(self.name)
166 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
168 def isConnected(self):
169 assert (self.edid is None) == (len(self._resolutions)==0), "Resolution-EDID mismatch; #resolutions: {}".format(len(self._resolutions))
170 return self.edid is not None
172 def addResolution(self, resolution):
173 assert isinstance(resolution, Resolution)
174 self._resolutions.add(resolution)
176 def appendToEdid(self, s):
177 if self.edid is None:
182 def setPreferredResolution(self, resolution):
183 assert isinstance(resolution, Resolution) and resolution in self._resolutions
184 self._preferredResolution = resolution
186 def getPreferredResolution(self):
187 if self._preferredResolution is not None:
188 return self._preferredResolution
189 return self.getResolutionList()[0] # prefer the largest resolution
191 def getResolutionList(self):
192 return sorted(self._resolutions, key=lambda r: -r.pixelCount())
194 class ScreenSituation:
195 connectors = [] # contains all the Connector objects
196 internalConnector = None # the internal Connector object (will be an enabled one)
197 externalConnector = None # the used external Connector object (an enabled one), or None
198 previousSetup = None # None or the ScreenSetup used the last time this external screen was connected
200 '''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'''
201 def __init__(self, internalConnectorNames, externalConnectorNames = None, xrandrSource = None):
202 '''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
203 just choose any remaining connector.'''
204 # which connectors are there?
205 self._getXrandrInformation(xrandrSource)
206 # figure out which is the internal connector
207 self.internalConnector = self._findAvailableConnector(internalConnectorNames)
208 if self.internalConnector is None:
209 raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
210 print("Detected internal connector:",self.internalConnector)
211 # and the external one
212 if externalConnectorNames is None:
213 externalConnectorNames = map(lambda c: c.name, self.connectors)
214 externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
215 self.externalConnector = self._findAvailableConnector(externalConnectorNames)
216 if self.internalConnector == self.externalConnector:
217 raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
218 print("Detected external connector:",self.externalConnector)
220 # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
221 def _getXrandrInformation(self, xrandrSource = None):
222 connector = None # current connector
224 if xrandrSource is None:
225 xrandrSource = processOutputGen("xrandr", "-q", "--verbose")
226 for line in processOutputGen("xrandr", "-q", "--verbose"):
228 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
230 connector.appendToEdid(m.group(1))
234 # fallthrough to the rest of the loop for parsing of this line
236 m = re.search(r'^Screen [0-9]+: ', line)
237 if m is not None: # ignore this line
241 m = re.search(r'^([\w\-]+) (dis)?connected ', line)
243 connector = Connector(m.group(1))
244 assert not any(c.name == connector.name for c in self.connectors)
245 if not connector.name.startswith("VIRTUAL"):
246 # skip "VIRTUAL" connectors
247 self.connectors.append(connector)
250 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
252 resolution = Resolution(int(m.group(1)), int(m.group(2)))
253 assert connector is not None
254 connector.addResolution(resolution)
255 if re.search(r' [+]preferred\b', line):
256 connector.setPreferredResolution(resolution)
259 m = re.search(r'^\s*EDID:\s*$', line)
264 # 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
266 # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
267 def _findAvailableConnector(self, tryConnectorNames):
268 for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
272 # return resolutions available for both internal and external screen
273 def commonResolutions(self):
274 assert self.externalConnector is not None, "Common resolutions may only be queried if there is an external screen connected."
275 internalRes = self.internalConnector.getResolutionList()
276 externalRes = self.externalConnector.getResolutionList()
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.previousSetup = db.getConfig(self.externalConnector.edid) # may also return None
301 self.previousSetup = None
302 if self.previousSetup:
303 print("Known screen, previous setup:", self.previousSetup)
304 self.externalConnector.previousResolution = self.previousSetup.extResolution
305 self.internalConnector.previousResolution = self.previousSetup.intResolution
307 def putDBInfo(self, db, setup):
308 if not self.externalConnector or not self.externalConnector.edid:
310 db.putConfig(self.externalConnector.edid, setup)