diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f09409f --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so +*.pyc + +# Packages # +############ +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log +*.sql +*.sqlite + +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +.venv/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/Realistic Random.indigoPlugin/Contents/Server Plugin/MenuItems.xml b/Realistic Random.indigoPlugin/Contents/Server Plugin/MenuItems.xml index ea0d913..5c6e596 100644 --- a/Realistic Random.indigoPlugin/Contents/Server Plugin/MenuItems.xml +++ b/Realistic Random.indigoPlugin/Contents/Server Plugin/MenuItems.xml @@ -1,5 +1,18 @@ + + Check for Updates + checkForUpdates + + + Update Plugin + updatePlugin + + + Force Update + forceUpdate + + Toggle Debugging toggleDebug diff --git a/Realistic Random.indigoPlugin/Contents/Server Plugin/ghpu.cfg b/Realistic Random.indigoPlugin/Contents/Server Plugin/ghpu.cfg new file mode 100644 index 0000000..d75fad6 --- /dev/null +++ b/Realistic Random.indigoPlugin/Contents/Server Plugin/ghpu.cfg @@ -0,0 +1,19 @@ +# this is a configuration file for the Indigo GitHub Plugin Updater +# it must be located in the plugin's 'Server Plugin' directory with plugin.py + +# for more information on the syntax of this file, see Python's ConfigParser module: +# https://docs.python.org/2/library/configparser.html + +[repository] + +# REQUIRED: the GitHub username for the owner of the plugin repository +owner = kmarkley + +# REQUIRED: the name of the repository where the plugin is located +name = Indigo-Realistic-Random + +# OPTIONAL: the path to the plugin inside the repository, defaults to base dir +path = Realistic Random.indigoPlugin + +# this section controls automatic update settings +[auto-update] diff --git a/Realistic Random.indigoPlugin/Contents/Server Plugin/ghpu.py b/Realistic Random.indigoPlugin/Contents/Server Plugin/ghpu.py new file mode 100644 index 0000000..0c89641 --- /dev/null +++ b/Realistic Random.indigoPlugin/Contents/Server Plugin/ghpu.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python2.5 + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# for the latest version and documentation: +# https://github.com/jheddings/indigo-ghpu + +import os +import tempfile +import subprocess +import shutil +import json +import httplib +import plistlib + +import ConfigParser + +from urllib2 import urlopen +from StringIO import StringIO +from zipfile import ZipFile +from collections import namedtuple + +PluginInfo = namedtuple('PluginInfo', ['id', 'name', 'version']) + +################################################################################ +class GitHubPluginUpdater(object): + + #--------------------------------------------------------------------------- + def __init__(self, plugin=None, configFile='ghpu.cfg'): + self.plugin = plugin + + config = ConfigParser.RawConfigParser() + config.read(configFile) + + self.repo = config.get('repository', 'name') + self.owner = config.get('repository', 'owner') + + if (config.has_option('repository', 'path')): + self.path = config.get('repository', 'path') + else: + self.path = '' + + # TODO error checking on configuration + + #--------------------------------------------------------------------------- + # install the latest version of the plugin represented by this updater + def install(self): + self._log('Installing plugin from %s/%s...' % (self.owner, self.repo)) + latestRelease = self.getLatestRelease() + + if (latestRelease == None): + self._error('No release available') + return False + + try: + self._installRelease(latestRelease) + except Exception as e: + self._error(str(e)) + return False + + return True + + #--------------------------------------------------------------------------- + # updates the contained plugin if needed + def update(self, currentVersion=None): + update = self._prepareForUpdate(currentVersion) + if (update == None): return False + + try: + self._installRelease(update) + except Exception as e: + self._error(str(e)) + return False + + return True + + #--------------------------------------------------------------------------- + # returns the URL for an update if there is one + def checkForUpdate(self, currentVersion=None): + update = self._prepareForUpdate(currentVersion) + + return (update != None) + + #--------------------------------------------------------------------------- + # returns the update package, if there is one + def getUpdate(self, currentVersion): + self._debug('Current version is: %s' % currentVersion) + + update = self.getLatestRelease() + + if (update == None): + self._debug('No release available') + return None + + # assume the tag is the release version + latestVersion = update['tag_name'].lstrip('v') + self._debug('Latest release is: %s' % latestVersion) + + if (ver(currentVersion) >= ver(latestVersion)): + return None + + return update + + #--------------------------------------------------------------------------- + # returns the latest release information from a given user / repo + # https://developer.github.com/v3/repos/releases/ + def getLatestRelease(self): + self._debug('Getting latest release from %s/%s...' % (self.owner, self.repo)) + return self._GET('/repos/' + self.owner + '/' + self.repo + '/releases/latest') + + #--------------------------------------------------------------------------- + # returns a tuple for the current rate limit: (limit, remaining, resetTime) + # https://developer.github.com/v3/rate_limit/ + # NOTE this does not count against the current limit + def getRateLimit(self): + limiter = self._GET('/rate_limit') + + remain = int(limiter['rate']['remaining']) + limit = int(limiter['rate']['limit']) + resetAt = int(limiter['rate']['reset']) + + return (limit, remain, resetAt) + + #--------------------------------------------------------------------------- + # form a GET request to api.github.com and return the parsed JSON response + def _GET(self, requestPath): + self._debug('GET %s' % requestPath) + + headers = { + 'User-Agent': 'Indigo-Plugin-Updater', + 'Accept': 'application/vnd.github.v3+json' + } + + data = None + + conn = httplib.HTTPSConnection('api.github.com') + conn.request('GET', requestPath, None, headers) + + resp = conn.getresponse() + self._debug('HTTP %d %s' % (resp.status, resp.reason)) + + if (resp.status == 200): + data = json.loads(resp.read()) + elif (400 <= resp.status < 500): + error = json.loads(resp.read()) + self._error('%s' % error['message']) + else: + self._error('Error: %s' % resp.reason) + + return data + + #--------------------------------------------------------------------------- + # prepare for an update + def _prepareForUpdate(self, currentVersion=None): + self._log('Checking for updates...') + + # sort out the currentVersion based on user params + if ((currentVersion == None) and (self.plugin == None)): + self._error('Must provide either currentVersion or plugin reference') + return None + elif (currentVersion == None): + currentVersion = str(self.plugin.pluginVersion) + self._debug('Plugin version detected: %s' % currentVersion) + else: + self._debug('Plugin version provided: %s' % currentVersion) + + update = self.getUpdate(currentVersion) + + if (update == None): + self._log('No updates are available') + return None + + self._error('A new version is available: %s' % update['html_url']) + + return update + + #--------------------------------------------------------------------------- + # reads plugin info from the given pList + def _buildPluginInfo(self, plist): + pid = plist.get('CFBundleIdentifier', None) + pname = pluginName = plist.get('CFBundleDisplayName', None) + pver = pluginVersion = plist.get('PluginVersion', None) + + return PluginInfo(id=pid, name=pname, version=pver) + + #--------------------------------------------------------------------------- + # reads the plugin info from the given path + def _readPluginInfoFromPath(self, path): + plistFile = os.path.join(path, 'Contents', 'Info.plist') + self._debug('Loading plugin info: %s' % plistFile) + + plist = plistlib.readPlist(plistFile) + + return self._buildPluginInfo(plist) + + #--------------------------------------------------------------------------- + # finds the plugin information in the zipfile + def _readPluginInfoFromArchive(self, zipfile): + topdir = zipfile.namelist()[0] + + # read and the plugin info contained in the zipfile + plistFile = os.path.join(topdir, self.path, 'Contents', 'Info.plist') + self._debug('Reading plugin info: %s' % plistFile) + + plistData = zipfile.read(plistFile) + if (plistData == None): + raise Exception('Unable to read new plugin info') + + plist = plistlib.readPlistFromString(plistData) + + return self._buildPluginInfo(plist) + + #--------------------------------------------------------------------------- + # verifies the provided plugin info matches what we expect + def _verifyPluginInfo(self, pInfo): + self._debug('Verifying plugin info: %s' % pInfo.id) + + if (pInfo.id == None): + raise Exception('ID missing in source') + elif (pInfo.name == None): + raise Exception('Name missing in source') + elif (pInfo.version == None): + raise Exception('Version missing in soruce') + + elif (self.plugin and (self.plugin.pluginId != pInfo.id)): + raise Exception('ID mismatch: %s' % pInfo.id) + + self._debug('Verified plugin: %s' % pInfo.name) + + #--------------------------------------------------------------------------- + # install a given release + def _installRelease(self, release): + tmpdir = tempfile.gettempdir() + self._debug('Workspace: %s' % tmpdir) + + # the zipfile is held in memory until we extract + zipfile = self._getZipFileFromRelease(release) + pInfo = self._readPluginInfoFromArchive(zipfile) + + self._verifyPluginInfo(pInfo) + + # the top level directory should be the first entry in the zipfile + # it is typically a combination of the owner, repo & release tag + repotag = zipfile.namelist()[0] + + # this is where the repo files will end up after extraction + repoBaseDir = os.path.join(tmpdir, repotag) + self._debug('Destination directory: %s' % repoBaseDir) + + if (os.path.exists(repoBaseDir)): + shutil.rmtree(repoBaseDir) + + # this is where the plugin will be after extracting + newPluginPath = os.path.join(repoBaseDir, self.path) + self._debug('Plugin source path: %s' % newPluginPath) + + # at this point, we should have been able to confirm the top-level directory + # based on reading the pluginId, we know the plugin in the zipfile matches our + # internal plugin reference (if we have one), temp directories are available + # and we know the package location for installing the plugin + + self._debug('Extracting files...') + zipfile.extractall(tmpdir) + + # now, make sure we got what we expected + if (not os.path.exists(repoBaseDir)): + raise Exception('Failed to extract plugin') + + self._installPlugin(newPluginPath) + self._debug('Installation complete') + + #--------------------------------------------------------------------------- + # install plugin from the existing path + def _installPlugin(self, pluginPath): + tmpdir = tempfile.gettempdir() + + pInfo = self._readPluginInfoFromPath(pluginPath) + self._verifyPluginInfo(pInfo) + + # if the new plugin path does not end in .indigoPlugin, we need to do some + # path shuffling for 'open' to work properly + if (not pluginPath.endswith('.indigoPlugin')): + stagedPluginPath = os.path.join(tmpdir, '%s.indigoPlugin' % pInfo.name) + self._debug('Staging plugin: %s' % stagedPluginPath) + + if (os.path.exists(stagedPluginPath)): + shutil.rmtree(stagedPluginPath) + + os.rename(pluginPath, stagedPluginPath) + pluginPath = stagedPluginPath + + self._debug('Installing %s' % pInfo.name) + subprocess.call(['open', pluginPath]) + + #--------------------------------------------------------------------------- + # return the valid zipfile from the release, or raise an exception + def _getZipFileFromRelease(self, release): + # download and verify zipfile from the release package + zipball = release.get('zipball_url', None) + if (zipball == None): + raise Exception('Invalid release package: no zipball') + + self._debug('Downloading zip file: %s' % zipball) + + zipdata = urlopen(zipball).read() + zipfile = ZipFile(StringIO(zipdata)) + + self._debug('Verifying zip file (%d bytes)...' % len(zipdata)) + if (zipfile.testzip() != None): + raise Exception('Download corrupted') + + return zipfile + + #--------------------------------------------------------------------------- + # convenience method for log messages + def _log(self, msg): + if self.plugin: + self.plugin.logger.info(msg) + + #--------------------------------------------------------------------------- + # convenience method for debug messages + def _debug(self, msg): + if self.plugin: + self.plugin.logger.debug(msg) + + #--------------------------------------------------------------------------- + # convenience method for error messages + def _error(self, msg): + if self.plugin: + self.plugin.logger.error(msg) + +################################################################################ +# maps the standard version string as a tuple for comparrison +def ver(vstr): return tuple(map(int, (vstr.split('.')))) + +################################################################################ +## stub plugin class for testing +class TestPluginStub(object): + + #--------------------------------------------------------------------------- + def __init__(self, version='0'): + self.pluginId = 'com.heddings.indigo.ghpu' + self.pluginName = 'Plugin Stub' + self.pluginVersion = version + + #--------------------------------------------------------------------------- + # expected logging methods + def log(self, msg): print '%s' % msg + def debugLog(self, msg): print '[DEBUG] %s' % msg + def errorLog(self, msg): print '[ERROR] %s' % msg + +################################################################################ +## TEST ENTRY +if __name__ == "__main__": + plugin = TestPluginStub() + + updater = GitHubPluginUpdater(plugin=plugin) + updater.update() diff --git a/Realistic Random.indigoPlugin/Contents/Server Plugin/plugin.py b/Realistic Random.indigoPlugin/Contents/Server Plugin/plugin.py index 082123c..4879339 100644 --- a/Realistic Random.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/Realistic Random.indigoPlugin/Contents/Server Plugin/plugin.py @@ -7,6 +7,7 @@ import time import datetime import random +from ghpu import GitHubPluginUpdater # Note the "indigo" module is automatically imported and made available inside @@ -23,13 +24,16 @@ 'maxDuration', ) +k_updateCheckHours = 24 + ################################################################################ class Plugin(indigo.PluginBase): - + #------------------------------------------------------------------------------- def __init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs): indigo.PluginBase.__init__(self, pluginId, pluginDisplayName, pluginVersion, pluginPrefs) - + self.updater = GitHubPluginUpdater(self) + def __del__(self): indigo.PluginBase.__del__(self) @@ -37,6 +41,7 @@ def __del__(self): # Start, Stop and Config changes #------------------------------------------------------------------------------- def startup(self): + self.nextCheck = self.pluginPrefs.get('nextUpdateCheck',0) self.debug = self.pluginPrefs.get("showDebugInfo",False) self.logger.debug("startup") if self.debug: @@ -46,6 +51,7 @@ def startup(self): #------------------------------------------------------------------------------- def shutdown(self): self.logger.debug("shutdown") + self.pluginPrefs['nextUpdateCheck'] = self.nextCheck self.pluginPrefs["showDebugInfo"] = self.debug #------------------------------------------------------------------------------- @@ -63,10 +69,12 @@ def runConcurrentThread(self): loopTime = time.time() for devId, dev in self.deviceDict.items(): dev.update() + if loopTime > self.nextCheck: + self.checkForUpdates() self.sleep(loopTime+5-time.time()) except self.StopThread: pass - + #------------------------------------------------------------------------------- # Device Methods #------------------------------------------------------------------------------- @@ -76,19 +84,19 @@ def deviceStartComm(self, dev): self.updateDeviceVersion(dev) if dev.configured: self.deviceDict[dev.id] = self.Randomizer(dev, self) - + #------------------------------------------------------------------------------- def deviceStopComm(self, dev): self.logger.debug("deviceStopComm: "+dev.name) if dev.id in self.deviceDict: #self.deviceDict[dev.id].cancel() del self.deviceDict[dev.id] - + #------------------------------------------------------------------------------- def validateDeviceConfigUi(self, valuesDict, typeId, devId, runtime=False): self.logger.debug("validateDeviceConfigUi: " + typeId) errorsDict = indigo.Dict() - + lightsList = [] for idx in ("{:0>2d}".format(i) for i in range(1,11)): lightId = valuesDict.get('devId'+idx,'') @@ -104,12 +112,12 @@ def validateDeviceConfigUi(self, valuesDict, typeId, devId, runtime=False): errorsDict[key+idx] = "Must be a positive integer" elif not ( 0 < int(valuesDict.get(key+idx)) < 481): errorsDict[key+idx] = "Must be between 1 and 480" - + if len(errorsDict) > 0: self.logger.debug('validate device config error: \n%s' % str(errorsDict)) return (False, valuesDict, errorsDict) return (True, valuesDict) - + #------------------------------------------------------------------------------- def updateDeviceVersion(self, dev): theProps = dev.pluginProps @@ -124,7 +132,7 @@ def updateDeviceVersion(self, dev): # push to server theProps["version"] = self.pluginVersion dev.replacePluginPropsOnServer(theProps) - + #------------------------------------------------------------------------------- # Action Methods #------------------------------------------------------------------------------- @@ -147,23 +155,36 @@ def actionControlDimmerRelay(self, action, dev): # UNKNOWN else: self.logger.debug('"{}" {} request ignored'.format(dev.name, unicode(action.deviceAction))) - + #------------------------------------------------------------------------------- def freezeRandomizerEffect(self, action): try: self.deviceDict[action.deviceId].cancel(False) except: self.logger.error('device "{}" not available'.format(action.deviceId)) - + #------------------------------------------------------------------------------- def forceRandomizerOff(self, action): try: self.deviceDict[action.deviceId].cancel(True) except: self.logger.error('device "{}" not available'.format(action.deviceId)) - + #------------------------------------------------------------------------------- # Menu Methods + #------------------------------------------------------------------------------- + def checkForUpdates(self): + self.updater.checkForUpdate() + self.nextCheck = time.time() + k_updateCheckHours*60*60 + + #------------------------------------------------------------------------------- + def updatePlugin(self): + self.updater.update() + + #------------------------------------------------------------------------------- + def forceUpdate(self): + self.updater.update(currentVersion='0.0.0') + #------------------------------------------------------------------------------- def toggleDebug(self): if self.debug: @@ -172,7 +193,7 @@ def toggleDebug(self): else: self.debug = True self.logger.debug("Debug logging enabled") - + #------------------------------------------------------------------------------- # Menu Callbacks #------------------------------------------------------------------------------- @@ -183,13 +204,13 @@ def getRelayDimmerDeviceList(self, filter="", valuesDict=None, typeId="", target if not dev.id in excludeList: devList.append((dev.id, dev.name)) devList.append((0,"- none -")) - return devList - + return devList + ############################################################################### # Classes ############################################################################### class Randomizer(object): - + #------------------------------------------------------------------------------- def __init__(self, instance, plugin): self.dev = instance @@ -197,35 +218,35 @@ def __init__(self, instance, plugin): self.props = self.dev.pluginProps self.states = self.dev.states self.nextUpdate = 0 - + self.logger = plugin.logger - + self.lightsList = list() for index in range(1,11): try: self.lightsList.append(self.ControlledLight(self.props, index, self)) except: pass - + #------------------------------------------------------------------------------- def update(self): if self.onState: for light in self.lightsList: light.update() self.nextUpdate = min(light.expire for light in self.lightsList) - + #------------------------------------------------------------------------------- def cancel(self, turnOff=False): self.onState = False for light in self.lightsList: light.cancel(turnOff) - + #------------------------------------------------------------------------------- # Class Properties #------------------------------------------------------------------------------- def onStateGet(self): return self.states['onOffState'] - + def onStateSet(self,newState): if newState != self.onState: self.logger.info('"{}" {}'.format(self.dev.name, ['off','on'][newState])) @@ -233,12 +254,12 @@ def onStateSet(self,newState): self.states = self.dev.states if newState: self.update() - + onState = property(onStateGet, onStateSet) - + ############################################################################### class ControlledLight(object): - + #------------------------------------------------------------------------------- def __init__(self, props, index, parent): indexString = "{:0>2d}".format(index) @@ -250,13 +271,13 @@ def __init__(self, props, index, parent): self.maxDur = int(props.get('maxDuration'+indexString,'60')) self.expire = 0 self.logger = parent.logger - + #------------------------------------------------------------------------------- def refresh(self): self.dev = indigo.devices[self.id] self.name = self.dev.name self.onState = self.dev.onState - + #------------------------------------------------------------------------------- def update(self): if self.expire < time.time(): @@ -271,7 +292,7 @@ def update(self): else: indigo.device.turnOn(self.id, duration=randomDuration, delay=randomDelay) self.expire = time.time() + randomDelay + randomDuration - + #------------------------------------------------------------------------------- def cancel(self, turnOff=False): self.refresh() @@ -281,4 +302,3 @@ def cancel(self, turnOff=False): if self.onState and turnOff: self.logger.debug('turn off "{}"'.format(self.name)) indigo.device.turnOff(self.id) -