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 == 12: # 16:12 = 4:3
92 elif ratio == 13: # 16:12.8 = 5:4
94 else: # let's just hope this will never be 14 or more...
95 strRatio = '16:%d' % ratio
96 return '%dx%d (%s)' %(self.width, self.height, strRatio)
99 return 'screen.Resolution('+self.forXrandr()+')'
101 def pixelCount(self):
102 return self.width * self.height
106 '''Represents a screen configuration (relative to some notion of an "internal" and an "external" screen): Which screens are enabled with which resolution, how
107 are they positioned, which is the primary screen.'''
108 def __init__(self, intResolution, extResolution, relPosition = None, extIsPrimary = True):
109 '''The resolutions can be None to disable the screen, instances of Resolution. The last two arguments only matter if both screens are enabled.'''
110 assert intResolution is None or isinstance(intResolution, Resolution)
111 assert extResolution is None or isinstance(extResolution, Resolution)
113 self.intResolution = intResolution
114 self.extResolution = extResolution
115 self.relPosition = relPosition
116 self.extIsPrimary = extIsPrimary or self.intResolution is None # external is always primary if it is the only one
118 def getInternalArgs(self):
119 if self.intResolution is None:
121 args = ["--mode", self.intResolution.forXrandr()] # set internal screen to desired resolution
122 if not self.extIsPrimary:
123 args.append('--primary')
126 def getExternalArgs(self, intName):
127 if self.extResolution is None:
129 args = ["--mode", self.extResolution.forXrandr()] # set external screen to desired resolution
130 if self.extIsPrimary:
131 args.append('--primary')
132 if self.intResolution is None:
136 RelativeScreenPosition.LEFT : '--left-of',
137 RelativeScreenPosition.RIGHT : '--right-of',
138 RelativeScreenPosition.ABOVE : '--above',
139 RelativeScreenPosition.BELOW : '--below',
140 RelativeScreenPosition.MIRROR: '--same-as',
141 }[self.relPosition], intName]
145 if self.intResolution is None:
146 return "External display only, at "+str(self.extResolution)
147 if self.extResolution is None:
148 return "Internal display only, at "+str(self.intResolution)
149 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))
152 def __init__(self, name=None):
153 self.name = name # connector name, e.g. "HDMI1"
154 self.edid = None # EDID string for the connector, or None if disconnected
155 self._resolutions = set() # list of Resolution objects, empty if disconnected
156 self.preferredResolution = None
157 self.__lastResolution = None
158 self.hasLastResolution = False
161 return str(self.name)
164 return """<Connector "%s" EDID="%s" resolutions="%s">""" % (str(self.name), str(self.edid), ", ".join(str(r) for r in self.getResolutionList()))
166 def __setLastRes(self, res):
167 # res == None means this display was last switched off
168 if res is not None and not res in self._resolutions:
169 raise ValueError("Resolution "+res+" not available for "+self.name+".")
170 self.__lastResolution = res
171 self.hasLastResolution = True
173 def __getLastRes(self):
174 if not self.hasLastResolution:
175 raise ValueError("Connector %s has no last known resolution." % self.name)
176 return self.__lastResolution
178 lastResolution = property(__getLastRes, __setLastRes)
180 def isConnected(self):
181 assert (self.edid is None) == (len(self._resolutions)==0)
182 return self.edid is not None
184 def addResolution(self, resolution):
185 assert isinstance(resolution, Resolution)
186 self._resolutions.add(resolution)
188 def appendToEdid(self, s):
189 if self.edid is None:
194 def getResolutionList(self):
195 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()))
197 class ScreenSituation:
198 connectors = [] # contains all the Connector objects
199 internalConnector = None # the internal Connector object (will be an enabled one)
200 externalConnector = None # the used external Connector object (an enabled one), or None
202 '''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'''
203 def __init__(self, internalConnectorNames, externalConnectorNames = None):
204 '''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
205 just choose any remaining connector.'''
206 # which connectors are there?
207 self._getXrandrInformation()
208 for c in self.connectors:
211 # figure out which is the internal connector
212 self.internalConnector = self._findAvailableConnector(internalConnectorNames)
213 if self.internalConnector is None:
214 raise Exception("Could not automatically find internal connector, please use (or fix) ~/.dsl.conf to specify it manually.")
215 print("Detected internal connector:",self.internalConnector)
216 # and the external one
217 if externalConnectorNames is None:
218 externalConnectorNames = map(lambda c: c.name, self.connectors)
219 externalConnectorNames = set(filter(lambda name: name != self.internalConnector.name, externalConnectorNames))
220 self.externalConnector = self._findAvailableConnector(externalConnectorNames)
221 if self.internalConnector == self.externalConnector:
222 raise Exception("Internal and external connector are the same. This must not happen. Please fix ~/.dsl.conf.");
223 print("Detected external connector:",self.externalConnector)
224 # self.lastSetup is left uninitialized so you can't access it before trying a lookup in the database
226 # Run xrandr and fill the dict of connector names mapped to lists of available resolutions.
227 def _getXrandrInformation(self):
228 connector = None # current connector
230 for line in processOutputGen("xrandr", "-q", "--verbose"):
232 m = re.match(r'^\s*([0-9a-f]+)\s*$', line)
234 connector.appendToEdid(m.group(1))
238 # fallthrough to the rest of the loop for parsing of this line
240 m = re.search(r'^Screen [0-9]+: ', line)
241 if m is not None: # ignore this line
245 m = re.search(r'^([\w\-]+) (dis)?connected ', line)
247 connector = Connector(m.group(1))
248 assert not any(c.name == connector.name for c in self.connectors)
249 self.connectors.append(connector)
252 m = re.search(r'^\s*([\d]+)x([\d]+)', line)
254 resolution = Resolution(int(m.group(1)), int(m.group(2)))
255 assert connector is not None
256 connector.addResolution(resolution)
257 if '+preferred' in line:
258 connector.preferredResolution = resolution
261 m = re.search(r'^\s*EDID:\s*$', line)
266 # not fatal, e.g. xrandr shows strange stuff when a display is enabled, but not connected
267 #print("Warning: Unknown xrandr line %s" % line)
269 # return the first available connector from those listed in <tryConnectorNames>, skipping disabled connectors
270 def _findAvailableConnector(self, tryConnectorNames):
271 for c in filter(lambda c: c.name in tryConnectorNames and c.isConnected(), self.connectors):
275 # return available internal resolutions
276 def internalResolutions(self):
277 return self.internalConnector.getResolutionList()
279 # return available external resolutions (or None, if there is no external screen connected)
280 def externalResolutions(self):
281 if self.externalConnector is None:
283 return self.externalConnector.getResolutionList()
285 # return resolutions available for both internal and external screen
286 def commonResolutions(self):
287 internalRes = self.internalResolutions()
288 externalRes = self.externalResolutions()
289 assert externalRes is not None
290 return sorted(set(externalRes).intersection(internalRes), key=lambda r: -r.pixelCount())
292 # compute the xrandr call
293 def forXrandr(self, setup):
294 # turn all screens off
295 connectorArgs = {} # maps connector names to xrand arguments
296 for c in self.connectors:
297 connectorArgs[c.name] = ["--off"]
298 # set arguments for the relevant ones
299 connectorArgs[self.internalConnector.name] = setup.getInternalArgs()
300 if self.externalConnector is not None:
301 connectorArgs[self.externalConnector.name] = setup.getExternalArgs(self.internalConnector.name)
303 assert setup.extResolution is None, "There's no external screen to set a resolution for"
304 # now compose the arguments
306 for name in connectorArgs:
307 call += ["--output", name] + connectorArgs[name]
310 def fetchDBInfo(self, db):
311 if self.externalConnector and self.externalConnector.edid:
312 self.lastSetup = db.getConfig(self.externalConnector.edid) # may also return None
314 self.lastSetup = None
316 print("SETUP FOUND", self.lastSetup)
317 self.externalConnector.lastResolution = self.lastSetup.extResolution
318 self.internalConnector.lastResolution = self.lastSetup.intResolution
320 print("NO SETUP FOUND")
322 def putDBInfo(self, db, setup):
323 if not self.externalConnector or not self.externalConnector.edid:
325 db.putConfig(self.externalConnector.edid, setup)