diff --git a/monitorDaemon.py b/monitorDaemon.py --- a/monitorDaemon.py +++ b/monitorDaemon.py @@ -1,841 +1,841 @@ #!/usr/bin/env python2 # monitorDaemon.py -# (c) 2017 Andreas Boehler +# (c) 2017-2018 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) + time.sleep(0.01) 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()