diff --git a/__init__.py b/__init__.py new file mode 100644 diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build2 +SPHINXPROJ = monitorDaemon +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/apidoc.rst b/doc/apidoc.rst new file mode 100644 --- /dev/null +++ b/doc/apidoc.rst @@ -0,0 +1,7 @@ +The monitorDaemon ApiDoc +------------------------ + +.. automodule:: monitorDaemon.monitorDaemon + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/stable/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) + + +# -- Project information ----------------------------------------------------- + +project = u'monitorDaemon' +copyright = u'2018, Andreas Böhler' +author = u'Andreas Böhler' + +# The short X.Y version +version = u'' +# The full version, including alpha/beta/rc tags +release = u'1.0' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = [u'_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'monitorDaemondoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'monitorDaemon.tex', u'monitorDaemon Documentation', + u'Andreas Böhler', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'monitordaemon', u'monitorDaemon Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'monitorDaemon', u'monitorDaemon Documentation', + author, 'monitorDaemon', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Extension configuration ------------------------------------------------- diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,33 @@ +.. monitorDaemon documentation master file, created by + sphinx-quickstart on Sun Mar 18 10:16:43 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to monitorDaemon's documentation! +========================================= + +monitorDaemon is a simple utility for Linux that manages screen configurations. +It monitors all hotplug events related to the display configuration and allows +you to store the screen configuration based on the exact screens connected. + +Technically, it uses the EDID information of a screen to uniquely identify the screen. + +Whenever monitorDaemon encounters the same configuration again, the stored +configuration is reloaded. + +Currently, automatic saving of screen configurations is broken. You have to use +the context menu to save a new screen configuration. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + prerequisites + installation + usage + apidoc + +Indices and tables +================== + +* :ref:`genindex` diff --git a/doc/installation.rst b/doc/installation.rst new file mode 100644 --- /dev/null +++ b/doc/installation.rst @@ -0,0 +1,15 @@ +Installation +------------ + +Installation is rather simple. If you are on Arch Linux, you can use +the provided PKGBUILD to build an Arch package. + +For other distributions, you need to either build a package yourself or +download the code and run it from the download directory. + +Be aware that it has been written for Arch Linux, where the Python 2 interpreter +is called ``python2``. Therefore, you may need to adapt the shebang line or +run ``monitorDaemon.py`` directly with your favourite Python interpreter by calling, +for example, ``python monitorDaemon.py``. + +The icon file needs to be placed as ``monitorDaemon.png`` in ``/usr/share/icons/`` diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=monitorDaemon + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/doc/prerequisites.rst b/doc/prerequisites.rst new file mode 100644 --- /dev/null +++ b/doc/prerequisites.rst @@ -0,0 +1,11 @@ +Prerequisites +------------- + +You need to be running Linux with an X server that provides the RandR extension. + +monitorDaemon requires Python 2, PyQt4 as well as the xrandr package for +python to be installed. + +Currently, it does not work with Python 3, since the ConfigParser module +has been changed significantly. Further, the xrandr interface for Python 3 +behaves differently under certain circumstances. diff --git a/doc/usage.rst b/doc/usage.rst new file mode 100644 --- /dev/null +++ b/doc/usage.rst @@ -0,0 +1,17 @@ +Usage +----- + +After installation, you can run ``monitorDaemon.py`` to find its icon +in the system tray. + +Whenever you have a screen configuration that you would like to remember, +simply right-click the icon and select "Save Current Configuration". It will +be stored and automatically loaded whenever this configuration is encountered again. + +If you click on "Load and Apply" you can manually trigger the loading of a configuration, +e.g. after messing the actual configuration up. + +You can configure the behaviour of the application by selecting "Settings" from +the right-click menu of the system tray icon. Currently, only the option "Load +on Startup" works correctly, which auto-loads the screen configuration on +application startup. diff --git a/monitorDaemon.py b/monitorDaemon.py --- a/monitorDaemon.py +++ b/monitorDaemon.py @@ -1,621 +1,841 @@ #!/usr/bin/env python2 # monitorDaemon.py # (c) 2017 Andreas Boehler # This file is free software; you can redistribute it and/or modify # it under the terms of either the GNU General Public License version 2 # or the GNU Lesser General Public License version 2.1, both as # published by the Free Software Foundation. import os import hashlib import sys import ConfigParser from Xlib import display from Xlib.ext import randr from PyQt4.QtCore import QThread, QTimer, pyqtSignal, QObject, QMetaObject, Qt, pyqtSlot from PyQt4 import QtGui import time import logging as log log.basicConfig(format="%(levelname)s:%(message)s", level=log.ERROR) #log.basicConfig(format="%(levelname)s: %(message)s", level=log.DEBUG) DEFAULT_TIMEOUT = 500 GRACE_TIMEOUT = 3000 VERBOSE = True class xrandrInterface(QThread): - + """ Provides an interface to the python implementation of xrandr. + + It does all the processing used to enable/disable monitors etc. + Inhertis from QThread. + """ screenConfigChanged = pyqtSignal() def __init__(self): + """ Initialize the module. """ QThread.__init__(self) self.display = display.Display() self.screen = self.display.screen() self.running = True self.window = self.screen.root.create_window(0, 0, 1, 1, 1, self.screen.root_depth, #event_mask = (X.ExposureMask | #X.StructureNotifyMask | #X.ButtonPressMask | #X.ButtonReleaseMask | #X.Button1MotionMask) ) self.outputStatus = {} self.oldOutputStatus = {} self.crtcStatus = {} self.getScreenInformation() self.enableEvents() self.timer = QTimer() self.timer.setSingleShot(True) self.timer.timeout.connect(self.screenInformationChanged) self.graceTimer = QTimer() self.graceTimer.setSingleShot(True) @pyqtSlot() def screenInformationChanged(self): + """ Is called when the daemon detects a change in the screen + configuration. + + :return: Nothing + """ log.debug("screenInformationChanged") self.getScreenInformation() self.screenConfigChanged.emit() def disableEvents(self): + """ Disables event processing in the xrandr interface. You can + call it to temporarily switch events off. + + :return: Nothing + """ log.debug("disableEvents") self.window.xrandr_select_input(0) def enableEvents(self): + """ Enable event processing in the xrandr interface. You need to call + it to switch events back on after calling :func:`disableEvents` + + :return: Nothing + """ log.debug("enableEvents") self.window.xrandr_select_input( randr.RRScreenChangeNotifyMask #| randr.RRCrtcChangeNotifyMask #| randr.RROutputChangeNotifyMask #| randr.RROutputPropertyNotifyMask ) def getScreenInformation(self): + """ Retrieves the current screen information from the XRandR extension + and stores it at self.outputStates as well as self.crtcStatus. + + It takes a while to process, be sure to not call it too often. + + :return: Nothing + """ log.debug("getScreenInformation") self.oldOutputStatus = self.outputStatus resources = self.window.xrandr_get_screen_resources()._data outputs = resources['outputs'] self.outputStatus = {} self.crtcStatus = {} for crtc in resources['crtcs']: crtcinfo = self.display.xrandr_get_crtc_info(crtc, resources['config_timestamp'])._data self.crtcStatus[crtc] = {} self.crtcStatus[crtc]['x'] = crtcinfo['x'] self.crtcStatus[crtc]['y'] = crtcinfo['y'] self.crtcStatus[crtc]['mode'] = crtcinfo['mode'] self.crtcStatus[crtc]['outputs'] = crtcinfo['outputs'] self.crtcStatus[crtc]['possible_outputs'] = crtcinfo['possible_outputs'] modedata = self.parseModes(resources['mode_names'], resources['modes']) for output in outputs: info = self.display.xrandr_get_output_info(output, resources['config_timestamp'])._data if info['connection'] != 0: continue edid = self.get_edid(output) name = info['name'] crtc = info['crtc'] if crtc == 0: modename = '0' posx = '0' posy = '0' else: mode = self.crtcStatus[crtc]['mode'] modename = modedata[mode]['name'] posx = self.crtcStatus[crtc]['x'] posy = self.crtcStatus[crtc]['y'] self.outputStatus[name] = {} self.outputStatus[name]['edid'] = edid self.outputStatus[name]['crtc'] = crtc self.outputStatus[name]['modename'] = modename self.outputStatus[name]['posx'] = posx self.outputStatus[name]['posy'] = posy def applyConfiguration(self, config): + """ Apply a configuration that has been previously stored. + + :param config: The configuration to apply + :return: Nothing + """ log.debug("applyConfiguration") resources = self.window.xrandr_get_screen_resources()._data outputs = resources['outputs'] # Find all available outputs and disable outputs # not present in the config for output in outputs: found = False for ii in range(0, int(config['edidcount'])): edid = self.get_edid(output) if edid: if config['edid' + str(ii+1)] == edid and config['mode' + str(ii+1)] != '0': found = True if not found: crtc = self.getCurrentCrtcForOutput(output) if crtc: self.disableCrtc(crtc) self.graceTimer.start(GRACE_TIMEOUT) # Apply stored screen configuration, based on EDID for ii in range(0, int(config['edidcount'])): if config['mode' + str(ii+1)] != '0': output = self.getOutputForEdid(config['edid' + str(ii+1)]) crtc = self.getCurrentCrtcForOutput(output) if not crtc: crtc = self.getUsableCrtcForOutput(output) mode = self.findModeByName(output, config['mode' + str(ii+1)]) self.configureCrtcForOutputAndMode(crtc, output, mode, int(config['posx' + str(ii+1)]), int(config['posy' + str(ii+1)])) self.updateScreenSize() self.getScreenInformation() log.debug("loadAndApply done.") def updateScreenSize(self): + """ Calculate the required screen size based on the connectec CRTC sizes + and update the virtual screen size accordingly. + + :return: Nothing + """ log.debug("updateScreenSize") # get all CRTC configs and calculate the required screen size # set screen size afterwards resources = self.window.xrandr_get_screen_resources()._data outputs = resources['outputs'] width = 0 height = 0 for output in outputs: oi = self.display.xrandr_get_output_info(output, resources['config_timestamp'])._data crtc = oi['crtc'] if crtc: info = self.display.xrandr_get_crtc_info(crtc, resources['config_timestamp'])._data w = info['x'] + info['width'] h = info['y'] + info['height'] if w > width: width = w if h > height: height = h # Do the same as the other RandR implementations: set width/height in mm # so that we match 96 dpi. mm_width = (width / 96.0) * 25.4 + 0.5; mm_height = (height / 96.0) * 25.4 + 0.5; self.window.xrandr_set_screen_size(width, height, mm_width, mm_height) def getScreenConfiguration(self): + """ Retrieve the current screen configuration. This is not live, i.e. + it simply returns the stored screen configuration based on hotplug events. + + :return: The output status + """ log.debug("getScreenConfiguration") return self.outputStatus def getOldScreenConfiguration(self): + """ Retrieve the previous screen configuration, i.e. the configuration + used before the last hotplug event fired. Can be used to determine + the difference between the configurations. + + :return: The old output status + """ log.debug("getOldScreenConfiguration") return self.oldOutputStatus def disableCrtc(self, crtc): + """ Disable a CRTC. + + :param crtc: The number of the CRTC to disable. + :return: Nothing + """ log.debug("disableCrtc") resources = self.window.xrandr_get_screen_resources()._data self.display.xrandr_set_crtc_config(crtc, resources['config_timestamp'], 0, 0, 0, randr.Rotate_0, []) def getCurrentCrtcForOutput(self, output): + """ Retrieve the CRTC for a given output. + + :param output: The output to work on + :return: The connected CRTC + """ log.debug("getCurrentCrtcForOutput") resources = self.window.xrandr_get_screen_resources()._data info = self.display.xrandr_get_output_info(output, resources['config_timestamp'])._data return info['crtc'] def configureCrtcForOutputAndMode(self, crtc, output, mode, posx, posy): + """ Configures a CRTC to a given output and a given mode. + + :param crtc: The CRTC to configure + :param output: The output to configure + :param mode: The new mode to set + :param posx: The X position of the screen + :param posy: The Y position of the screen + :return: Nothing + """ log.debug("configureCrtcForOutputAndMode") resources = self.window.xrandr_get_screen_resources()._data self.display.xrandr_set_crtc_config(crtc, resources['config_timestamp'], posx, posy, mode, randr.Rotate_0, [output]) def getOutputForOutputname(self, name): + """ Retrieve the output number by output name + + :param name: The output name + :return: The output number + """ log.debug("getOutputForOutputName") resources = self.window.xrandr_get_screen_resources()._data for output in resources['outputs']: info = self.display.xrandr_get_output_info(output, resources['config_timestamp'])._data if info['name'] == name: return output return None def getUsableCrtcForOutput(self, output): + """ Return the first CRTC an output can be connected to. + + :param output: The output to retrieve information from + :return: The CRTC number or None + """ log.debug("getUsableCrtcForOuput") resources = self.window.xrandr_get_screen_resources()._data for crtc in resources['crtcs']: crtcinfo = self.display.xrandr_get_crtc_info(crtc, resources['config_timestamp'])._data if len(crtcinfo['outputs']) == 0: if output in crtcinfo['possible_outputs']: return crtc return None def findModeByName(self, output, modename): + """ Find the internal mode number associated with a given mode name. + + :param output: The output to work with + :param modename: The modename to retrieve + :return: The mode number or None + """ log.debug("findModeByName") resources = self.window.xrandr_get_screen_resources()._data modedata = self.parseModes(resources['mode_names'], resources['modes']) info = self.display.xrandr_get_output_info(output, resources['config_timestamp'])._data modes = info['modes'] for mode in modes: if modedata[mode]['name'] == modename: return mode return None def parseModes(self, mode_names, modes): + """ Parse the mode names returned by an output into name and data dict + + :param mode_names: The mode names to extract + :param modes: The raw mode data + :return: A dict with matched up mode data and mode names + """ log.debug("parseModes") lastIdx = 0 modedatas = dict() for mode in modes: modedata = dict(mode._data) modedata['name'] = mode_names[lastIdx:lastIdx + modedata['name_length']] modedatas[modedata['id']] = modedata lastIdx += modedata['name_length'] return modedatas def getOutputForEdid(self, edid): + """ Return the output a screen with a given EDID is connected to. + + :param edid: The EDID to look for + :return: The output number or None + """ log.debug("getOuputForEdid") resources = self.window.xrandr_get_screen_resources()._data for output in resources['outputs']: medid = self.get_edid(output) if edid == medid: return output return None # query the edid module for output_nr def get_edid(self, output_nr): + """ Retrieve the EDID for a given output + + :param output_nr: The output to look for + :return: The hexdigest of the EDID or None + """ log.debug("get_edid") PROPERTY_EDID = self.display.intern_atom("EDID", 0) INT_TYPE = 19 try: props = self.display.xrandr_list_output_properties(output_nr) except: log.error("Exception gettint output properties") return None if PROPERTY_EDID in props.atoms: try: rawedid = self.display.xrandr_get_output_property(output_nr, PROPERTY_EDID, INT_TYPE, 0, 400) except: log.error("Error loading EDID data of output ", output_nr) return None edidstream = rawedid._data['value'] hx = bytearray() hx.extend(edidstream) hs = hashlib.sha1(hx).hexdigest() return hs else: return None def run(self): + """ The main loop of the thread. + + It constantly pools the xrandr interface for new events, + since doing blocked I/O sometimes locks up the interface. + + New events a processed on demand, especially, the change of + screen information. + + A grace timer protects the events to be processed several + times. + """ while self.running: ev = self.display.pending_events() if(ev == 0): time.sleep(0.001) continue e = self.display.next_event() log.debug("Got Event: " + str(e._data)) # Window has been destroyed, quit #if e.type == X.DestroyNotify: # sys.exit(0) # Screen information has changed if e.type == self.display.extension_event.ScreenChangeNotify: log.debug("ScreenChangeNotify") # print(e._data) # Trigger a QTimer here for a few seconds that # then updates the screen information and # runs the update code # If a timer is running, reset it to the default if not self.graceTimer.isActive(): log.info("Starting Timer...") self.timer.stop() self.timer.start(DEFAULT_TIMEOUT) else: log.debug("graceTimer active") # CRTC information has changed #elif e.type == self.display.extension_event.CrtcChangeNotify: # print('CRTC change') # print(e._data) # Output information has changed #elif e.type == self.display.extension_event.OutputChangeNotify: # print('Output change') # print(e._data) # Output property information has changed #elif e.type == self.display.extension_event.OutputPropertyNotify: # print('Output property change') # print(e._data) # Somebody wants to tell us something #elif e.type == X.ClientMessage: # if e.client_type == self.WM_PROTOCOLS: # fmt, data = e.data # if fmt == 32 and data[0] == self.WM_DELETE_WINDOW: # sys.exit(0) class ConfigManager: + """ Provides an interface to the configuration file """ def __init__(self, cfgfile): + """ Initialize the module """ self.cfgfile = cfgfile if not os.path.exists(cfgfile): self.recreateCfg() self.dic = {} self.cfg2dic() def getDic(self): + """ Retrieve the current configuration + + :return: The dictionary + """ return self.dic def recreateCfg(self): + """ Create a new configuration file, overwriting an existing file. + + :return: Nothing + """ log.info('Creating new config file: ' + self.cfgfile) config = ConfigParser.ConfigParser() config.add_section('General') config.set('General', 'numentries', '0') config.set('General', 'autosave', '0') config.set('General', 'loadonstartup', '1') with open(self.cfgfile, 'w') as configfile: config.write(configfile) def cfg2dic(self): + """ Convert the configuration file to a dictionary. + + :return: The parsed dictionary + """ dic = {} config = ConfigParser.ConfigParser() config.read(self.cfgfile) for section in config.sections(): dic[section] = {} for key, value in config.items(section): dic[section][key] = value self.dic = dic return dic def dic2cfg(self): + """ Convert the dictionary to the configuration file and save it. + + :return: Nothing + """ config = ConfigParser.ConfigParser() for section in self.dic: config.add_section(section) for key in self.dic[section]: config.set(section, key, self.dic[section][key]) with open(self.cfgfile, 'w') as configfile: config.write(configfile) def getConfig(self, screenConfiguration): + """ Retrieve the stored configuration mathing a given + screen configuration. + + :return: A tuple with the configuration and the name or (None, None) + """ ne = int(self.dic['General']['numentries']) for ii in range(0, ne): name = 'Config' + str(ii+1) if int(self.dic[name]['edidcount']) != len(screenConfiguration): continue found = True for outputName in screenConfiguration: lFound = False for jj in range(0, len(screenConfiguration)): if self.dic[name]['edid' + str(jj+1)] == screenConfiguration[outputName]['edid']: lFound = True if lFound and found: found = True else: found = False if found: #for jj in range(0, len(screenConfiguration)): return (self.dic[name], name) return (None, None) def autosaveEnabled(self): + """ Check if autosave is enabled. + + :return: bool + """ if self.dic['General']['autosave'] == '1': return True return False def loadOnStartupEnabled(self): + """ Check if load on startup is enabled. + + :return: bool + """ if self.dic['General']['loadonstartup'] == '1': return True return False def getApplicationConfig(self): + """ Return the application configuration dictionary, i.e. the + "General" section. + + :return: The dictionary configuration + """ return self.dic['General'] def saveApplicationConfig(self, cfg): + """ Save the application configuration, i.e. the "General" section. + + :param cfg: The dictionary with the configuration to save + :return: Nothing + """ ne = self.dic['General'] for key in cfg: self.dic['General'][key] = cfg[key] self.dic2cfg() def saveConfiguration(self, screenConfiguration): + """ Save a given screen configuration to the configuratoin file, + overwriting an existing identical configuration. + + :param screenConfiguration: The screen configuration to save + :return: bool + """ log.info("saveConfiguration") oldConfig, oldname = self.getConfig(screenConfiguration) if oldConfig: name = oldname else: ne = int(self.dic['General']['numentries']) ne += 1 self.dic['General']['numentries'] = str(ne) name = 'Config' + str(ne) self.dic[name] = {} self.dic[name]['edidcount'] = str(len(screenConfiguration)) cnt = 1 for outputName in screenConfiguration.keys(): self.dic[name]['edid'+str(cnt)] = screenConfiguration[outputName]['edid'] self.dic[name]['output'+str(cnt)] = outputName self.dic[name]['mode'+str(cnt)] = screenConfiguration[outputName]['modename'] self.dic[name]['posx'+str(cnt)] = str(screenConfiguration[outputName]['posx']) self.dic[name]['posy'+str(cnt)] = str(screenConfiguration[outputName]['posy']) cnt += 1 self.dic2cfg() return True class SettingsGui(QtGui.QWidget): - + """ This module contains the graphical user interface for the user-configurable + settings. + """ loadSettings = pyqtSignal() saveSettings = pyqtSignal([dict]) def __init__(self, parent=None): + """ Initialize the module. """ self.parent = parent QtGui.QWidget.__init__(self, parent) self.setWindowTitle('monitorDaemon Settings GUI') grid = QtGui.QGridLayout() self.setLayout(grid) lbl1 = QtGui.QLabel('Load on Startup') lbl2 = QtGui.QLabel('AutoSave') self.cbAutoSave = QtGui.QCheckBox() self.cbLoadOnStartup = QtGui.QCheckBox() btn1 = QtGui.QPushButton('Save') btn2 = QtGui.QPushButton('Quit') grid.addWidget(lbl1, 1, 1) grid.addWidget(self.cbLoadOnStartup, 1, 2) grid.addWidget(lbl2, 2, 1) grid.addWidget(self.cbAutoSave, 2, 2) grid.addWidget(btn1, 3, 1) grid.addWidget(btn2, 3, 2) btn1.clicked.connect(self.saveBtnClicked) btn2.clicked.connect(self.quit) @pyqtSlot() def quit(self): + """ Slot that is called when the application should be quit. + + :return: Nothing + """ QtGui.qApp.quit() @pyqtSlot() def saveBtnClicked(self): + """ Slot that is invoked when the save button has been clicked. + + :return: Nothing + """ cfg = {} if self.cbAutoSave.isChecked(): cfg['autosave'] = '1' else: cfg['autosave'] = '0' if self.cbLoadOnStartup.isChecked(): cfg['loadonstartup'] = '1' else: cfg['loadonstartup'] = '0' self.saveSettings.emit(cfg) def toggleVisibility(self, reason): + """ Toggle the visibility of the settings manager. + + :return: Nothing + """ if reason != QtGui.QSystemTrayIcon.Trigger: return if self.isVisible(): self.hide() else: self.loadSettings.emit() self.show() @pyqtSlot(dict) def settingsLoaded(self, settings): + """ Slot that is called by the when the + settings GUI should be initialized with values. + + :return: Nothing + """ + if settings['autosave'] == '1': self.cbAutoSave.setChecked(True) else: self.cbAutoSave.setChecked(False) if settings['loadonstartup'] == '1': self.cbLoadOnStartup.setChecked(True) else: self.cbLoadOnStartup.setChecked(False) class SystemTrayIcon(QtGui.QSystemTrayIcon): - + """ Provides the system tray icon. """ def __init__(self, icon, m, parent=None): + """ Initialize the module and connect signals/slots. """ self.parent = parent QtGui.QSystemTrayIcon.__init__(self, icon, parent) menu = QtGui.QMenu(parent) saveAction = menu.addAction("Save Current Configuration") saveAction.triggered.connect(m.saveConfig) loadAction = menu.addAction("Load and Apply Configuration") loadAction.triggered.connect(m.loadAndApply) exitAction = menu.addAction("Exit") exitAction.triggered.connect(m.quit) self.setContextMenu(menu) self.settingsGui = SettingsGui() self.activated.connect(self.settingsGui.toggleVisibility) self.settingsGui.loadSettings.connect(m.loadSettings) self.settingsGui.saveSettings.connect(m.saveSettings) m.settingsLoaded.connect(self.settingsGui.settingsLoaded) class Manager(QObject): + """ Handles interaction between SystemTrayIcon, Settings and XRandR interface. """ applyConfig = pyqtSignal([dict]) settingsLoaded = pyqtSignal([dict]) def __init__(self, config, xri, parent = None): + """ Initialize the module. """ QObject.__init__(self) self.config = config self.parent = parent self.xri = xri #self.xri.start() @pyqtSlot() def saveConfig(self): + """ Slot that is called when the user clicks the "Save Settings" button. + + :return: Nothing + """ screenConfig = self.xri.getScreenConfiguration() self.config.saveConfiguration(screenConfig) @pyqtSlot() def loadAndApply(self): + """ Slot that is called when the user clicks the "Load and Apply" button. + + :return: Nothing + """ log.info("loadAndApply") screenConfig = self.xri.getScreenConfiguration() storedConfig, name = self.config.getConfig(screenConfig) if storedConfig: self.applyConfig.emit(storedConfig) @pyqtSlot(dict) def saveSettings(self, cfg): + """ Slot that is called by the settings GUI to save application settings. + + :return: Nothing + """ self.config.saveApplicationConfig(cfg) @pyqtSlot() def loadSettings(self): + """ Slot that is called by the settings GUI to load the application settings. + + :return: Nothing + """ cfg = self.config.getApplicationConfig() self.settingsLoaded.emit(cfg) @pyqtSlot() def loadOnStartup(self): + """ Slot that is called when the application starts up. It loads the + current configuration if it matches and auto-load on startup is enabled. + + :return: Nothing + """ log.info("loadOnStartup") if self.config.loadOnStartupEnabled(): self.loadAndApply() @pyqtSlot() def screenConfigurationChanged(self): + """ Slot that is called by the XRandR interface when the screen + information changes. + + This is probably one of the most important methods since it triggers + the retrieval of the stored configuration and applies the configuration + if necessary. + + :return: Nothing + """ log.info("screenConfigurationChanged") oldConfig = self.xri.getOldScreenConfiguration() newConfig = self.xri.getScreenConfiguration() if len(oldConfig.keys()) != len(newConfig.keys()): self.loadAndApply() else: load = False # Check every output, if we encounter one with a changed # EDID, then we need to load and apply the configuration # otherwise, nothing has changed - save it? for output in oldConfig: if newConfig.has_key(output): if oldConfig[output]['edid'] == newConfig[output]['edid']: continue load = True break if load: self.loadAndApply() else: if self.config.autosaveEnabled(): self.saveConfig() @pyqtSlot() def quit(self): + """ Slot that is called when the aplication is to quit. """ QtGui.qApp.quit() def main(): + """ Main application startup. Initialize the configuration file, + the GUI and load applicatoin settings. Fire up the XRandR interface and + listen for events. + + There are no shared queues or objects, since all communication is done + via Qt signals/slots. + """ app = QtGui.QApplication(sys.argv) app.setQuitOnLastWindowClosed(False); settingsdir = os.path.join(os.environ['HOME'], '.config') cfgfile = os.path.join(settingsdir, 'monitorDaemon.ini') cm = ConfigManager(cfgfile) w = QtGui.QWidget() x = xrandrInterface() m = Manager(cm, x, w) x.screenConfigChanged.connect(m.screenConfigurationChanged) m.applyConfig.connect(x.applyConfiguration) QMetaObject.invokeMethod(x, "start", Qt.QueuedConnection) QMetaObject.invokeMethod(m, "loadOnStartup", Qt.QueuedConnection) # Wait for up to 30 seconds for the systemTray to become available (required for, e.g., Xfce) count = 0 while not QtGui.QSystemTrayIcon.isSystemTrayAvailable() and count < 30: time.sleep(1) count += 1 trayIcon = SystemTrayIcon(QtGui.QIcon("/usr/share/icons/monitorDaemon.png"), m, w) trayIcon.show() sys.exit(app.exec_()) if __name__ == '__main__': + """ Call the main method if we run standalone. """ main()