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
54 '''Represents a resolution of a screen'''
55 def __init__(self, width, height):
60 def fromDatabase(cls, dbstr):
63 parts = dbstr.split("x")
65 raise ValueError(xrandrstr)
66 return Resolution(*map(int,parts))
68 def forDatabase(self):
69 return str(self.width)+'x'+str(self.height)
72 return self.forDatabase()
75 return (self.width, self.height)
77 def __eq__(self, other):
78 if not isinstance(other, Resolution):
80 return self.width == other.width and self.height == other.height
82 def __ne__(self, other):
83 return not self.__eq__(other)
86 return hash(("Resolution",self.width,self.height))
90 ratio = Fraction(self.width, self.height) # automatically divides by the gcd
91 strRatio = "%d:%d" % (ratio.numerator, ratio.denominator)
92 return '%dx%d (%s)' %(self.width, self.height, strRatio)
95 return 'screen.Resolution('+self.forXrandr()+')'
98 return self.width * self.height
102 '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
103 are they positioned, which is the primary screen.'''
104 def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
105 '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
106 assert intResolution is None or isinstance(intResolution, Resolution)
107 assert extResolution is None or isinstance(extResolution, Resolution)
109 self.intResolution = intResolution
110 self.extResolution = extResolution
111 self.relPosition = relPosition
112 self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
114 def getInternalArgs(self):
115 if self.intResolution is None:
117 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
118 if not self.extIsPrimary:
119 args.append('--primary')
122 def getExternalArgs(self, intName):
123 if self.extResolution is None:
125 args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
126 if self.extIsPrimary:
127 args.append('--primary')
128 if self.intResolution is None:
132 RelativeScreenPosition.LEFT : '--left-of',
133 RelativeScreenPosition.RIGHT : '--right-of',
134 RelativeScreenPosition.ABOVE : '--above',
135 RelativeScreenPosition.BELOW : '--below',
136 RelativeScreenPosition.MIRROR: '--same-as',
137 }[self.relPosition], intName]
141 if self.intResolution is None:
142 return "External display only, at "+str(self.extResolution)
143 if self.extResolution is None:
144 return "Internal display only, at "+str(self.intResolution)
145 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))
148 def __init__(self, name=None):
149 self.name = name # connector name, e.g. "HDMI1"
150 self.edid = None # EDID string for the connector, or None if disconnected
151 self._resolutions = set() # list of Resolution objects, empty if disconnected
152 self.preferredResolution = None
153 self.__lastResolution = None
154 self.hasLastResolution = False
157 return str(self.name)
160 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
162 def __setLastRes(self, res):
163 # res == None means this display was last switched off
164 if res is not None and not res in self._resolutions:
165 raise ValueError("Resolution "+res+" not available for "+self.name+".")
166 self.__lastResolution = res
167 self.hasLastResolution = True
169 def __getLastRes(self):
170 if not self.hasLastResolution:
171 raise ValueError("Connector %s has no last known resolution." % self.name)
172 return self.__lastResolution
174 lastResolution = property(__getLastRes, __setLastRes)
176 def isConnected(self):
177 assert (self.edid is None) == (len(self._resolutions)==0)
178 return self.edid is not None
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 getResolutionList(self):
191 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()))
193 class ScreenSituation:
194 connectors = [] # contains all the Connector objects
195 internalConnector = None # the internal Connector object (will be an enabled one)
196 externalConnector = None # the used external Connector object (an enabled one), or None
198 '''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'''
199 def __init__(self, internalConnectorNames, externalConnectorNames = None):
200 '''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
201 just choose any remaining connector.'''
202 # which connectors are there?
203 self._getXrandrInformation()
204 for c in self.connectors:
207 # figure out which is the internal connector
208 self.internalConnector = self._findAvailableConnector(internalConnectorNames)
209 if self.internalConnector is None:
210 raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
211 print("Detected internal connector:",self.internalConnector)
212 # and the external one
213 if externalConnectorNames is None:
214 externalConnectorNames = map(lambda c: c.name, self.connectors)
215 externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
216 self.externalConnector = self._findAvailableConnector(externalConnectorNames)
217 if self.internalConnector == self.externalConnector:
218 raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
219 print("Detected external connector:",self.externalConnector)
220 # self.lastSetup is left uninitialized so you can't access it before trying a lookup in the database
222 # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
223 def _getXrandrInformation(self):
224 connector = None # current connector
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 self.connectors.append(connector)
248 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
250 resolution = Resolution(int(m.group(1)), int(m.group(2)))
251 assert connector is not None
252 connector.addResolution(resolution)
253 if '+preferred' in line:
254 connector.preferredResolution = resolution
257 m = re.search(r'^\s*EDID:\s*$', line)
262 # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
263 #print("Warning: Unknown xrandr line %s" % line)
265 # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
266 def _findAvailableConnector(self, tryConnectorNames):
267 for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
271 # return available internal resolutions
272 def internalResolutions(self):
273 return self.internalConnector.getResolutionList()
275 # return available external resolutions (or None, if there is no external screen connected)
276 def externalResolutions(self):
277 if self.externalConnector is None:
279 return self.externalConnector.getResolutionList()
281 # return resolutions available for both internal and external screen
282 def commonResolutions(self):
283 internalRes = self.internalResolutions()
284 externalRes = self.externalResolutions()
285 assert externalRes is not None
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.lastSetup = db.getConfig(self.externalConnector.edid) # may also return None
310 self.lastSetup = None
312 print("SETUP FOUND", self.lastSetup)
313 self.externalConnector.lastResolution = self.lastSetup.extResolution
314 self.internalConnector.lastResolution = self.lastSetup.intResolution
316 print("NO SETUP FOUND")
318 def putDBInfo(self, db, setup):
319 if not self.externalConnector or not self.externalConnector.edid:
321 db.putConfig(self.externalConnector.edid, setup)