add tests for parsing certain xrandr output; fix crash when screen doesn't have an...
[lilass.git] / lilass
1 #!/usr/bin/env python3
2 # DSL - easy Display Setup for Laptops
3 # Copyright (C) 2012-2015 Ralf Jung <post@ralfj.de>
4 #
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.
9 #
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.
14 #
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.
18
19 import argparse, sys, os, os.path, shutil, re, subprocess
20 from enum import Enum
21 import gui, screen, util, database
22 frontend = gui.getFrontend("cli") # the fallback, until we got a proper frontend. This is guaranteed to be available.
23 cmdArgs = None
24
25 # Load a section-less config file: maps parameter names to space-separated lists of strings (with shell quotation)
26 def loadConfigFile(filename):
27     import shlex
28     result = {}
29     if not os.path.exists(filename):
30         return result # no config file
31     # read config file
32     linenr = 0
33     with open(filename) as f:
34         for line in f:
35             linenr += 1
36             line = line.strip()
37             if not len(line) or line.startswith("#"): continue # skip empty and comment lines
38             try:
39                 # parse line
40                 pos = line.index("=") # will raise exception when substring is not found
41                 curKey = line[:pos].strip()
42                 result[curKey] = shlex.split(line[pos+1:]) # shlex.split also strips
43             except Exception:
44                 raise Exception("Invalid config, line %d: Error parsing line (may be a quoting issue)." % linenr)
45     # add some convencience get functions
46     return result
47
48
49 # Make sure the backlight is turned on
50 def turnOnBacklight():
51     try:
52         backlight = float(subprocess.check_output(["xbacklight", "-get"]).strip())
53         if backlight == 0: # it's completely turned off, we better enable it
54             subprocess.check_call(["xbacklight", "-set", "100"])
55     except FileNotFoundError:
56         print("xbacklight has not been found, unable to turn your laptop backlight on.")
57     except subprocess.CalledProcessError:
58         print("xbacklight returned an error while attempting to turn your laptop backlight on.")
59
60
61 # return the current sceen situation, using the configuration to control connecor detection
62 def situationByConfig(config):
63     # internal connectors
64     if 'internalConnector' in config:
65         if len(config['internalConnector']) != 1:
66             raise Exception("You must specify exactly one internal connector.")
67         internalConnectors = config['internalConnector']
68     else:
69         internalConnectors = screen.commonInternalConnectorNames()
70     # run!
71     return screen.ScreenSituation(internalConnectors, config.get('externalConnectors'))
72
73 # if we run top-level
74 if __name__ == "__main__":
75     try:
76         # how do we filter the RelativeScreenPosition for the CLI?
77         relPosFilter = str.lower
78         
79         # parse command-line arguments
80         parser = argparse.ArgumentParser(description='easy Display Setup for Laptops')
81         parser.add_argument("-f", "--frontend",
82                             dest="frontend",
83                             help="The frontend to be used for user interaction")
84         parser.add_argument("-r", "--relative-position",
85                             dest="rel_position", choices=list(map(relPosFilter, screen.RelativeScreenPosition.__members__.keys())),
86                             help="Set the position of external screen relative to internal one, in case it is not found in the DB.")
87         parser.add_argument("-e", "--external-only",
88                             dest="external_only", action='store_true',
89                             help="If an external screen is connected, disable all the others.")
90         parser.add_argument("-i", "--internal-only",
91                             dest="internal_only", action='store_true',
92                             help="Enable internal screen, disable all the others.")
93         parser.add_argument("-s", "--silent",
94                             dest="silent", action='store_true',
95                             help="Prefer to be silent: Opens a UI only if the external screen is not known *and* no default configuration (-r/-e/-i) is given.")
96         parser.add_argument("--no-db",
97                             dest="use_db", action='store_false',
98                             help="Do not use the database of known screens.")
99         parser.add_argument("-v", "--verbose",
100                             dest="verbose", action='store_true',
101                             help="More verbose output on stderr.")
102         cmdArgs = parser.parse_args()
103     
104         # load frontend early (for error mssages)
105         frontend = gui.getFrontend(cmdArgs.frontend)
106         
107         # find files
108         ## find config file
109         legacyConfigFilePath = os.getenv('HOME') + '/.lilass.conf'
110         configDirectory = util.getConfigDirectory()
111         configFilePath = os.path.join(configDirectory, "lilass.conf")
112         if os.path.isfile(legacyConfigFilePath) and not os.path.isfile(configFilePath):
113             # looks like we just upgraded to a new version of lilass
114             util.mkdirP(configDirectory)
115             shutil.move(legacyConfigFilePath, configFilePath)
116         ## find database
117         dataDirectory = util.getDataDirectory()
118         util.mkdirP(dataDirectory)
119         databaseFilePath = os.path.join(dataDirectory, "collected_data.sqlite")
120
121         # load configuration
122         config = loadConfigFile(configFilePath)
123         
124         # see what situation we are in
125         situation = situationByConfig(config)
126         
127         # construct the ScreenSetup
128         setup = None
129         if situation.externalConnector is not None:
130             # There's an external screen connected that we may want to use.
131             # Fetch info about this screen from the database.
132             # NOTE: If it is too slow to open the DB twice (reading and saving), we can keep it open all the time
133             if cmdArgs.use_db:
134                 with database.Database(databaseFilePath) as db:
135                     situation.fetchDBInfo(db)
136             # what to we do?
137             have_default_conf = bool(cmdArgs.external_only or cmdArgs.internal_only or cmdArgs.rel_position)
138             no_ui = bool(have_default_conf or (situation.previousSetup and cmdArgs.silent))
139             if not no_ui:
140                 # ask the user what to do
141                 setup = frontend.setup(situation)
142                 if setup is None: sys.exit(1) # the user canceled
143                 if cmdArgs.use_db:
144                     # persists this to disk
145                     with database.Database(databaseFilePath) as db:
146                         situation.putDBInfo(db, setup)
147             elif situation.previousSetup:
148                 # apply the old setup again
149                 setup = situation.previousSetup
150             # use default config from CLI
151             elif cmdArgs.external_only:
152                 setup = screen.ScreenSetup(intResolution = None, extResolution = situation.externalConnector.getPreferredResolution())
153             elif cmdArgs.rel_position is not None:
154                 # construct automatically, based on CLI arguments
155                 # first, figure out the desired RelativeScreenPosition... what a bad hack...
156                 relPos = list(filter(lambda relPosItem: relPosFilter(relPosItem[0]) == cmdArgs.rel_position, screen.RelativeScreenPosition.__members__.items()))
157                 assert len(relPos) == 1, "CLI argument is ambiguous"
158                 relPos = relPos[0][1]
159                 # now we construct the ScreenSetup
160                 if relPos == screen.RelativeScreenPosition.MIRROR:
161                     res = situation.commonResolutions()[0]
162                     setup = screen.ScreenSetup(res, res, relPos)
163                 else:
164                     setup = screen.ScreenSetup(intResolution = situation.internalConnector.getPreferredResolution(),
165                                                extResolution = situation.externalConnector.getPreferredResolution(),
166                                                relPosition = relPos)
167             # cmdArgs.internal_only: fall-through
168         if setup is None:
169             assert cmdArgs.internal_only or situation.externalConnector is None
170             # Nothing chosen yet? Use first resolution of internal connector.
171             setup = screen.ScreenSetup(intResolution = situation.internalConnector.getPreferredResolution(), extResolution = None)
172         
173         # call xrandr
174         xrandrCall = situation.forXrandr(setup)
175         print("Call that will be made:",xrandrCall)
176         subprocess.check_call(xrandrCall)
177         
178         # make sure the internal screen is really, *really* turned on if there is no external screen
179         if setup.extResolution is None:
180             turnOnBacklight()
181     except Exception as e:
182         frontend.error(str(e))
183         if cmdArgs is None or cmdArgs.verbose:
184             raise(e)