From e4999ca039e55d174ac077c35310d17b797326cf Mon Sep 17 00:00:00 2001 From: Henric Andersson Date: Fri, 19 Feb 2021 22:51:36 -0800 Subject: [PATCH 01/20] First commit for python3 support --- MIGRATION.md | 2 ++ frame.py | 4 ++-- modules/debug.py | 3 +-- modules/dedupe.py | 5 +++-- modules/display.py | 6 +++--- modules/memory.py | 2 +- modules/server.py | 10 +++++----- modules/servicemanager.py | 4 ++-- modules/settings.py | 4 ++-- modules/slideshow.py | 3 +-- modules/sysconfig.py | 4 ++-- routes/control.py | 2 +- routes/debug.py | 2 +- routes/details.py | 4 ++-- routes/events.py | 2 +- routes/keywords.py | 2 +- routes/maintenance.py | 2 +- routes/oauthlink.py | 4 ++-- routes/options.py | 2 +- routes/orientation.py | 2 +- routes/overscan.py | 2 +- routes/pages.py | 2 +- routes/service.py | 2 +- routes/settings.py | 2 +- routes/upload.py | 2 +- services/base.py | 6 +++--- services/svc_googlephotos.py | 2 +- services/svc_picasaweb.py | 4 ++-- services/svc_simpleurl.py | 2 +- services/svc_usb.py | 14 +++++++------- 30 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..f6f15a9 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,2 @@ +sudo apt install apt-utils git fbset python3-requests python3-requests-oauthlib python3-flask python3-flask-httpauth imagemagick python3-smbus bc + diff --git a/frame.py b/frame.py index 306e57f..d09fa05 100755 --- a/frame.py +++ b/frame.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # This file is part of photoframe (https://github.com/mrworf/photoframe). # @@ -61,7 +61,7 @@ class Photoframe: def __init__(self, cmdline): self.void = open(os.devnull, 'wb') - random.seed(long(time.clock())) + random.seed(int(time.monotonic())) self.emulator = cmdline.emulate if self.emulator: diff --git a/modules/debug.py b/modules/debug.py index ef960ea..48f8cb4 100755 --- a/modules/debug.py +++ b/modules/debug.py @@ -14,7 +14,6 @@ # along with photoframe. If not, see . # import subprocess -import logging import os import datetime import sys @@ -43,7 +42,7 @@ def subprocess_check_output(cmds, stderr=None): def stacktrace(): title = 'Stacktrace of all running threads' lines = [] - for threadId, stack in sys._current_frames().items(): + for threadId, stack in list(sys._current_frames().items()): lines.append("\n# ThreadID: %s" % threadId) for filename, lineno, name, line in traceback.extract_stack(stack): lines.append('File: "%s", line %d, in %s' % (filename, lineno, name)) diff --git a/modules/dedupe.py b/modules/dedupe.py index 1b72cc2..c857457 100755 --- a/modules/dedupe.py +++ b/modules/dedupe.py @@ -13,12 +13,13 @@ # You should have received a copy of the GNU General Public License # along with photoframe. If not, see . # +import logging class DedupeManager: def __init__(self, memoryLocation): try: - from PIL import Image - import imagehash + #from PIL import Image + #import imagehash self.hasImageHash = True logging.info('ImageHash functionality is available') except: diff --git a/modules/display.py b/modules/display.py index 0f09943..9ffaa3f 100755 --- a/modules/display.py +++ b/modules/display.py @@ -19,10 +19,10 @@ import time import re import json -import debug +from . import debug -from sysconfig import sysconfig -from helper import helper +from .sysconfig import sysconfig +from .helper import helper class display: def __init__(self, use_emulator=False, emulate_width=1280, emulate_height=720): diff --git a/modules/memory.py b/modules/memory.py index b57a96f..fed5b5b 100755 --- a/modules/memory.py +++ b/modules/memory.py @@ -26,7 +26,7 @@ def __init__(self, memoryLocation): self._MEMORY_COUNT = {} def _hashString(self, text): - if type(text) is not unicode: + if type(text) is not str: # make sure it's unicode a = text.decode('ascii', errors='replace') else: diff --git a/modules/server.py b/modules/server.py index 91744b9..cdb624b 100755 --- a/modules/server.py +++ b/modules/server.py @@ -35,15 +35,15 @@ def __init__(self): def login_required(self, fn): def wrap(*args, **kwargs): return fn(*args, **kwargs) - wrap.func_name = fn.func_name + wrap.__name__ = fn.__name__ return wrap class WebServer(Thread): - def __init__(self, async=False, port=7777, listen='0.0.0.0'): + def __init__(self, run_async=False, port=7777, listen='0.0.0.0'): Thread.__init__(self) self.port = port self.listen = listen - self.async = async + self.run_async = run_async self.app = Flask(__name__, static_url_path='/--do--not--ever--use--this--') self.app.config['UPLOAD_FOLDER'] = '/tmp/' @@ -78,7 +78,7 @@ def _logincheck(self): return self.authmethod() def start(self): - if self.async: + if self.run_async: self.start() else: self.run() @@ -99,7 +99,7 @@ def stop(self): def run(self): try: self.app.run(debug=False, port=self.port, host=self.listen ) - except RuntimeError, msg: + except RuntimeError as msg: if str(msg) == "Server shutdown": pass # or whatever you want to do when the server goes down else: diff --git a/modules/servicemanager.py b/modules/servicemanager.py index dcef0e7..7bd3102 100755 --- a/modules/servicemanager.py +++ b/modules/servicemanager.py @@ -96,7 +96,7 @@ def _resolveService(self, id): def listServices(self): result = [] # Make sure it retains the ID sort order - for key, value in sorted(self._SVC_INDEX.iteritems(), key=lambda (k,v): (v['id'],k)): + for key, value in sorted(iter(self._SVC_INDEX.items()), key=lambda k_v: (k_v[1]['id'],k_v[0])): result.append(self._SVC_INDEX[key]) return result; @@ -168,7 +168,7 @@ def deleteService(self, id): if id not in self._SERVICES: return - self._HISTORY = filter(lambda h: h != self._SERVICES[id]['service'], self._HISTORY) + self._HISTORY = [h for h in self._HISTORY if h != self._SERVICES[id]['service']] del self._SERVICES[id] self._deletefolder(os.path.join(self._BASEDIR, id)) self._configChanged() diff --git a/modules/settings.py b/modules/settings.py index f23e61c..0f471ab 100755 --- a/modules/settings.py +++ b/modules/settings.py @@ -17,7 +17,7 @@ import json import logging import random -from path import path +from .path import path class settings: DEPRECATED_USER = ['resolution'] @@ -94,7 +94,7 @@ def load(self): logging.exception('Failed to load settings.json, corrupt file?') return False # make sure old settings.json files are still compatible and get updated with new keys - if "cachefolder" not in self.settings.keys(): + if "cachefolder" not in list(self.settings.keys()): self.settings["cachefolder"] = path.CACHEFOLDER return True else: diff --git a/modules/slideshow.py b/modules/slideshow.py index ccaf924..1a6b1ea 100755 --- a/modules/slideshow.py +++ b/modules/slideshow.py @@ -114,7 +114,6 @@ def createEvent(self, cmd): def handleEvents(self): showNext = True - isRandom = self.settings.getUser("randomize_images") while len(self.eventList) > 0: event = self.eventList.pop(0) @@ -258,7 +257,7 @@ def delayNextImage(self, time_process): def showPreloadedImage(self, image): if not os.path.isfile(image.filename): - logging.warning("Trying to show image '%s', but file does not exist!" % filename) + logging.warning("Trying to show image '%s', but file does not exist!" % image.filename) self.delayer.set() return self.display.image(image.filename) diff --git a/modules/sysconfig.py b/modules/sysconfig.py index 90619d7..e5c65ad 100755 --- a/modules/sysconfig.py +++ b/modules/sysconfig.py @@ -18,7 +18,7 @@ import re import subprocess -from path import path +from .path import path import logging class sysconfig: @@ -162,7 +162,7 @@ def setHostname(name): # First, make sure it's legal name = re.sub(' ', '-', name.strip()); name = re.sub('[^a-zA-Z0-9\-]', '', name).strip() - if name is '' or len(name) > 63: + if not name or len(name) > 63: return False # Next, let's edit the relevant files.... diff --git a/routes/control.py b/routes/control.py index 6612696..906b8d9 100644 --- a/routes/control.py +++ b/routes/control.py @@ -14,7 +14,7 @@ # along with photoframe. If not, see . # -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteControl(BaseRoute): def setupex(self, slideshow): diff --git a/routes/debug.py b/routes/debug.py index 76c48be..93fbc98 100644 --- a/routes/debug.py +++ b/routes/debug.py @@ -15,7 +15,7 @@ # import modules.debug as debug -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteDebug(BaseRoute): SIMPLE = True # We have no dependencies to the rest of the system diff --git a/routes/details.py b/routes/details.py index a79b21f..389c173 100755 --- a/routes/details.py +++ b/routes/details.py @@ -19,7 +19,7 @@ from modules.helper import helper -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteDetails(BaseRoute): def setupex(self, displaymgr, drivermgr, colormatch, slideshow, servicemgr, settings): @@ -46,7 +46,7 @@ def handle(self, app, about): response.headers.set('Content-Type', mime) return response elif about == 'drivers': - result = self.drivermgr.list().keys() + result = list(self.drivermgr.list().keys()) return self.jsonify(result) elif about == 'timezone': result = helper.timezoneList() diff --git a/routes/events.py b/routes/events.py index e819c9a..a6aca1f 100755 --- a/routes/events.py +++ b/routes/events.py @@ -14,7 +14,7 @@ # along with photoframe. If not, see . # -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteEvents(BaseRoute): def setupex(self, events): diff --git a/routes/keywords.py b/routes/keywords.py index 08c946e..0300560 100755 --- a/routes/keywords.py +++ b/routes/keywords.py @@ -14,7 +14,7 @@ # along with photoframe. If not, see . # -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteKeywords(BaseRoute): def setupex(self, servicemgr, slideshow): diff --git a/routes/maintenance.py b/routes/maintenance.py index b91fd57..762f638 100755 --- a/routes/maintenance.py +++ b/routes/maintenance.py @@ -17,7 +17,7 @@ import subprocess import shutil -from baseroute import BaseRoute +from .baseroute import BaseRoute from modules.path import path class RouteMaintenance(BaseRoute): diff --git a/routes/oauthlink.py b/routes/oauthlink.py index a98c688..4fe5b10 100644 --- a/routes/oauthlink.py +++ b/routes/oauthlink.py @@ -17,7 +17,7 @@ import logging import json -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteOAuthLink(BaseRoute): def setupex(self, servicemgr, slideshow): @@ -29,7 +29,7 @@ def setupex(self, servicemgr, slideshow): self.addUrl('/service//oauth').clearMethods().addMethod('POST') def handle(self, app, **kwargs): - print(self.getRequest().url) + print((self.getRequest().url)) if '/callback?' in self.getRequest().url: # Figure out who should get this result... old = self.servicemgr.hasReadyServices() diff --git a/routes/options.py b/routes/options.py index 52e9257..cb8bed4 100644 --- a/routes/options.py +++ b/routes/options.py @@ -15,7 +15,7 @@ # from modules.sysconfig import sysconfig -from baseroute import BaseRoute +from .baseroute import BaseRoute #@app.route('/options/') class RouteOptions(BaseRoute): diff --git a/routes/orientation.py b/routes/orientation.py index 7ddef93..f6559a6 100644 --- a/routes/orientation.py +++ b/routes/orientation.py @@ -15,7 +15,7 @@ # from modules.sysconfig import sysconfig -from baseroute import BaseRoute +from .baseroute import BaseRoute #@auth.login_required class RouteOrientation(BaseRoute): diff --git a/routes/overscan.py b/routes/overscan.py index f15c08c..5b9da82 100644 --- a/routes/overscan.py +++ b/routes/overscan.py @@ -15,7 +15,7 @@ # from modules.sysconfig import sysconfig -from baseroute import BaseRoute +from .baseroute import BaseRoute #@auth.login_required class RouteOverscan(BaseRoute): diff --git a/routes/pages.py b/routes/pages.py index 5bb2ecc..0142fec 100644 --- a/routes/pages.py +++ b/routes/pages.py @@ -17,7 +17,7 @@ import os from flask import send_from_directory -from baseroute import BaseRoute +from .baseroute import BaseRoute class RoutePages(BaseRoute): SIMPLE = True diff --git a/routes/service.py b/routes/service.py index c2b88c2..86d6992 100644 --- a/routes/service.py +++ b/routes/service.py @@ -14,7 +14,7 @@ # along with photoframe. If not, see . # -from baseroute import BaseRoute +from .baseroute import BaseRoute #@app.route('/service/', methods=['GET', 'POST']) #@auth.login_required diff --git a/routes/settings.py b/routes/settings.py index 98c90b8..db9a058 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -14,7 +14,7 @@ # along with photoframe. If not, see . # -from baseroute import BaseRoute +from .baseroute import BaseRoute from modules.helper import helper from modules.shutdown import shutdown diff --git a/routes/upload.py b/routes/upload.py index 61fccea..8d29dba 100644 --- a/routes/upload.py +++ b/routes/upload.py @@ -18,7 +18,7 @@ import os from werkzeug.utils import secure_filename -from baseroute import BaseRoute +from .baseroute import BaseRoute class RouteUpload(BaseRoute): def setupex(self, settingsmgr, drivermgr): diff --git a/services/base.py b/services/base.py index b0972d2..d3b1c6d 100755 --- a/services/base.py +++ b/services/base.py @@ -209,7 +209,7 @@ def getMessages(self): 'link': None } ) - if 0 in self._STATE["_NUM_IMAGES"].values(): + if 0 in list(self._STATE["_NUM_IMAGES"].values()): # Find first keyword with zero (unicode issue) removeme = [] for keyword in self._STATE["_KEYWORDS"]: @@ -218,7 +218,7 @@ def getMessages(self): msgs.append( { 'level': 'WARNING', - 'message': 'The following keyword(s) do not yield any photos: %s' % ', '.join(map(u'"{0}"'.format, removeme)), + 'message': 'The following keyword(s) do not yield any photos: %s' % ', '.join(map('"{0}"'.format, removeme)), 'link': None } ) @@ -758,7 +758,7 @@ def getStoragePath(self): return self._DIR_PRIVATE def hashString(self, text): - if type(text) is not unicode: + if type(text) is not str: # make sure it's unicode a = text.decode('ascii', errors='replace') else: diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py index ff1ff96..10ffdf9 100755 --- a/services/svc_googlephotos.py +++ b/services/svc_googlephotos.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with photoframe. If not, see . # -from base import BaseService +from .base import BaseService import os import json import logging diff --git a/services/svc_picasaweb.py b/services/svc_picasaweb.py index cf0accc..9da5b19 100644 --- a/services/svc_picasaweb.py +++ b/services/svc_picasaweb.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with photoframe. If not, see . # -from base import BaseService +from .base import BaseService import random import os import json @@ -154,5 +154,5 @@ def getImagesFor(self, keyword): if os.path.exists(filename): with open(filename, 'r') as f: images = json.load(f) - print(repr(images)) + print((repr(images))) return images diff --git a/services/svc_simpleurl.py b/services/svc_simpleurl.py index 53a6b51..2ac4770 100755 --- a/services/svc_simpleurl.py +++ b/services/svc_simpleurl.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with photoframe. If not, see . # -from base import BaseService +from .base import BaseService import logging from modules.helper import helper diff --git a/services/svc_usb.py b/services/svc_usb.py index 7bc9a6b..b392124 100755 --- a/services/svc_usb.py +++ b/services/svc_usb.py @@ -14,7 +14,7 @@ # along with photoframe. If not, see . # -from base import BaseService +from .base import BaseService import subprocess import os import logging @@ -124,7 +124,7 @@ def getKeywords(self): # No, you can't have an album called /photoframe/ALLALBUMS ... keywords.remove("ALLALBUMS") albums = self.getAllAlbumNames() - keywords.extend(filter(lambda a: a not in keywords, albums)) + keywords.extend([a for a in albums if a not in keywords]) if "ALLALBUMS" in albums: logging.error("You should not have a album called 'ALLALBUMS'!") @@ -253,7 +253,7 @@ def mountStorageDevice(self): self.device = candidate self.checkForInvalidKeywords() return True - except subprocess.CalledProcessError: + except subprocess.CalledProcessError as e: logging.warning('Unable to mount storage device "%s" to "%s"!' % (candidate.device, self.usbDir)) logging.warning('Output: %s' % repr(e.output)) self.unmountBaseDir() @@ -270,10 +270,10 @@ def unmountBaseDir(self): # All images directly inside '/photoframe' directory will be displayed without any keywords def getBaseDirImages(self): - return filter(lambda x: os.path.isfile(os.path.join(self.baseDir, x)), os.listdir(self.baseDir)) + return [x for x in os.listdir(self.baseDir) if os.path.isfile(os.path.join(self.baseDir, x))] def getAllAlbumNames(self): - return filter(lambda x: os.path.isdir(os.path.join(self.baseDir, x)), os.listdir(self.baseDir)) + return [x for x in os.listdir(self.baseDir) if os.path.isdir(os.path.join(self.baseDir, x))] def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize): if self.device is None: @@ -293,11 +293,11 @@ def getImagesFor(self, keyword): return [] images = [] if keyword == "_PHOTOFRAME_": - files = filter(lambda x: not x.startswith("."), self.getBaseDirImages()) + files = [x for x in self.getBaseDirImages() if not x.startswith(".")] images = self.getAlbumInfo(self.baseDir, files) else: if os.path.isdir(os.path.join(self.baseDir, keyword)): - files = filter(lambda x: not x.startswith("."), os.listdir(os.path.join(self.baseDir, keyword))) + files = [x for x in os.listdir(os.path.join(self.baseDir, keyword)) if not x.startswith(".")] images = self.getAlbumInfo(os.path.join(self.baseDir, keyword), files) else: logging.warning("The album '%s' does not exist. Did you unplug the storage device associated with '%s'?!" % (os.path.join(self.baseDir, keyword), self.device)) From 901ec005afe11d85e980e85fdc5987037ce0bbe4 Mon Sep 17 00:00:00 2001 From: Henric Andersson Date: Fri, 19 Feb 2021 23:31:27 -0800 Subject: [PATCH 02/20] Baby steps towards being able to use flake8 --- frame.py | 256 +++--- modules/cachemanager.py | 311 +++---- modules/colormatch.py | 325 +++---- modules/debug.py | 82 +- modules/dedupe.py | 47 +- modules/display.py | 767 ++++++++--------- modules/drivers.py | 479 +++++------ modules/events.py | 83 +- modules/helper.py | 734 ++++++++-------- modules/history.py | 91 +- modules/images.py | 173 ++-- modules/memory.py | 149 ++-- modules/network.py | 7 +- modules/oauth.py | 247 +++--- modules/path.py | 65 +- modules/remember.py | 75 +- modules/server.py | 254 +++--- modules/servicemanager.py | 893 ++++++++++---------- modules/settings.py | 311 +++---- modules/shutdown.py | 81 +- modules/slideshow.py | 633 +++++++------- modules/sysconfig.py | 361 ++++---- modules/timekeeper.py | 236 +++--- routes/baseroute.py | 91 +- routes/control.py | 14 +- routes/debug.py | 54 +- routes/details.py | 148 ++-- routes/events.py | 27 +- routes/keywords.py | 79 +- routes/maintenance.py | 121 +-- routes/oauthlink.py | 62 +- routes/options.py | 34 +- routes/orientation.py | 30 +- routes/overscan.py | 26 +- routes/pages.py | 15 +- routes/service.py | 77 +- routes/settings.py | 136 +-- routes/upload.py | 89 +- services/base.py | 1550 +++++++++++++++++----------------- services/svc_googlephotos.py | 782 +++++++++-------- services/svc_picasaweb.py | 275 +++--- services/svc_simpleurl.py | 181 ++-- services/svc_usb.py | 672 ++++++++------- tox.ini | 6 + 44 files changed, 5659 insertions(+), 5470 deletions(-) create mode 100644 tox.ini diff --git a/frame.py b/frame.py index d09fa05..a57be1e 100755 --- a/frame.py +++ b/frame.py @@ -42,7 +42,8 @@ # Make sure we run from our own directory os.chdir(os.path.dirname(sys.argv[0])) -parser = argparse.ArgumentParser(description="PhotoFrame - A RaspberryPi based digital photoframe", formatter_class=argparse.ArgumentDefaultsHelpFormatter) +parser = argparse.ArgumentParser(description="PhotoFrame - A RaspberryPi based digital photoframe", + formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('--logfile', default=None, help="Log to file instead of stdout") parser.add_argument('--port', default=7777, type=int, help="Port to listen on") parser.add_argument('--countdown', default=10, type=int, help="Set seconds to countdown before starting slideshow") @@ -54,132 +55,141 @@ cmdline = parser.parse_args() if cmdline.debug: - logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') else: - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + class Photoframe: - def __init__(self, cmdline): - self.void = open(os.devnull, 'wb') - random.seed(int(time.monotonic())) - - self.emulator = cmdline.emulate - if self.emulator: - self.enableEmulation() - if cmdline.basedir is not None: - self.changeRoot(cmdline.basedir) - if not path().validate(): - sys.exit(255) - - self.eventMgr = Events() - self.eventMgr.add('Hello world') - - self.cacheMgr = CacheManager() - self.settingsMgr = settings() - self.displayMgr = display(self.emulator) - # Validate all settings, prepopulate with defaults if needed - self.validateSettings() - - self.imageHistory = ImageHistory(self.settingsMgr) - self.driverMgr = drivers() - self.serviceMgr = ServiceManager(self.settingsMgr, self.cacheMgr) - - self.colormatch = colormatch(self.settingsMgr.get('colortemp-script'), 2700) # 2700K = Soft white, lowest we'll go - self.slideshow = slideshow(self.displayMgr, self.settingsMgr, self.colormatch, self.imageHistory) - self.timekeeperMgr = timekeeper() - self.timekeeperMgr.registerListener(self.displayMgr.enable) - self.powerMgr = shutdown(self.settingsMgr.getUser('shutdown-pin')) - - self.cacheMgr.validate() - self.cacheMgr.enableCache(self.settingsMgr.getUser('enable-cache') == 1) - - # Tie all the services together as needed - self.timekeeperMgr.setConfiguration(self.settingsMgr.getUser('display-on'), self.settingsMgr.getUser('display-off')) - self.timekeeperMgr.setAmbientSensitivity(self.settingsMgr.getUser('autooff-lux'), self.settingsMgr.getUser('autooff-time')) - self.timekeeperMgr.setPowermode(self.settingsMgr.getUser('powersave')) - self.colormatch.setUpdateListener(self.timekeeperMgr.sensorListener) - - self.timekeeperMgr.registerListener(self.slideshow.shouldShow) - self.slideshow.setServiceManager(self.serviceMgr) - self.slideshow.setCacheManager(self.cacheMgr) - self.slideshow.setCountdown(cmdline.countdown) - - # Prep the webserver - self.setupWebserver(cmdline.listen, cmdline.port) - - # Force display to desired user setting - self.displayMgr.enable(True, True) - - def updating(self, x, y): - self.slideshow.stop(self.updating_continue) - - def updating_continue(self): - self.displayMgr.message('Updating software', False) - self.webServer.stop() - logging.debug('Entering hover mode, waiting for update to finish') - while True: # This is to allow our subprocess to run! - time.sleep(30) - - def _loadRoute(self, module, klass, *vargs): - module = importlib.import_module('routes.' + module) - klass = getattr(module, klass) - route = eval('klass()') - route.setupex(*vargs) - self.webServer.registerHandler(route) - - def setupWebserver(self, listen, port): - test = WebServer(port=port, listen=listen) - self.webServer = test - - self._loadRoute('settings', 'RouteSettings', self.powerMgr, self.settingsMgr, self.driverMgr, self.timekeeperMgr, self.displayMgr, self.cacheMgr, self.slideshow) - self._loadRoute('keywords', 'RouteKeywords', self.serviceMgr, self.slideshow) - self._loadRoute('orientation', 'RouteOrientation', self.cacheMgr) - self._loadRoute('overscan', 'RouteOverscan', self.cacheMgr) - self._loadRoute('maintenance', 'RouteMaintenance', self.emulator, self.driverMgr, self.slideshow) - self._loadRoute('details', 'RouteDetails', self.displayMgr, self.driverMgr, self.colormatch, self.slideshow, self.serviceMgr, self.settingsMgr) - self._loadRoute('upload', 'RouteUpload', self.settingsMgr, self.driverMgr) - self._loadRoute('oauthlink', 'RouteOAuthLink', self.serviceMgr, self.slideshow) - self._loadRoute('service', 'RouteService', self.serviceMgr, self.slideshow) - self._loadRoute('control', 'RouteControl', self.slideshow) - self._loadRoute('events', 'RouteEvents', self.eventMgr) - - def validateSettings(self): - if not self.settingsMgr.load(): - # First run, grab display settings from current mode - current = self.displayMgr.current() - if current is not None: - logging.info('No display settings, using: %s' % repr(current)) - self.settingsMgr.setUser('tvservice', '%s %s HDMI' % (current['mode'], current['code'])) + def __init__(self, cmdline): + self.void = open(os.devnull, 'wb') + random.seed(int(time.monotonic())) + + self.emulator = cmdline.emulate + if self.emulator: + self.enableEmulation() + if cmdline.basedir is not None: + self.changeRoot(cmdline.basedir) + if not path().validate(): + sys.exit(255) + + self.eventMgr = Events() + self.eventMgr.add('Hello world') + + self.cacheMgr = CacheManager() + self.settingsMgr = settings() + self.displayMgr = display(self.emulator) + # Validate all settings, prepopulate with defaults if needed + self.validateSettings() + + self.imageHistory = ImageHistory(self.settingsMgr) + self.driverMgr = drivers() + self.serviceMgr = ServiceManager(self.settingsMgr, self.cacheMgr) + + self.colormatch = colormatch(self.settingsMgr.get('colortemp-script'), + 2700) # 2700K = Soft white, lowest we'll go + self.slideshow = slideshow(self.displayMgr, self.settingsMgr, self.colormatch, self.imageHistory) + self.timekeeperMgr = timekeeper() + self.timekeeperMgr.registerListener(self.displayMgr.enable) + self.powerMgr = shutdown(self.settingsMgr.getUser('shutdown-pin')) + + self.cacheMgr.validate() + self.cacheMgr.enableCache(self.settingsMgr.getUser('enable-cache') == 1) + + # Tie all the services together as needed + self.timekeeperMgr.setConfiguration(self.settingsMgr.getUser('display-on'), + self.settingsMgr.getUser('display-off')) + self.timekeeperMgr.setAmbientSensitivity(self.settingsMgr.getUser( + 'autooff-lux'), self.settingsMgr.getUser('autooff-time')) + self.timekeeperMgr.setPowermode(self.settingsMgr.getUser('powersave')) + self.colormatch.setUpdateListener(self.timekeeperMgr.sensorListener) + + self.timekeeperMgr.registerListener(self.slideshow.shouldShow) + self.slideshow.setServiceManager(self.serviceMgr) + self.slideshow.setCacheManager(self.cacheMgr) + self.slideshow.setCountdown(cmdline.countdown) + + # Prep the webserver + self.setupWebserver(cmdline.listen, cmdline.port) + + # Force display to desired user setting + self.displayMgr.enable(True, True) + + def updating(self, x, y): + self.slideshow.stop(self.updating_continue) + + def updating_continue(self): + self.displayMgr.message('Updating software', False) + self.webServer.stop() + logging.debug('Entering hover mode, waiting for update to finish') + while True: # This is to allow our subprocess to run! + time.sleep(30) + + def _loadRoute(self, module, klass, *vargs): + module = importlib.import_module('routes.' + module) + klass = getattr(module, klass) + route = eval('klass()') + route.setupex(*vargs) + self.webServer.registerHandler(route) + + def setupWebserver(self, listen, port): + test = WebServer(port=port, listen=listen) + self.webServer = test + + self._loadRoute('settings', 'RouteSettings', self.powerMgr, self.settingsMgr, self.driverMgr, + self.timekeeperMgr, self.displayMgr, self.cacheMgr, self.slideshow) + self._loadRoute('keywords', 'RouteKeywords', self.serviceMgr, self.slideshow) + self._loadRoute('orientation', 'RouteOrientation', self.cacheMgr) + self._loadRoute('overscan', 'RouteOverscan', self.cacheMgr) + self._loadRoute('maintenance', 'RouteMaintenance', self.emulator, self.driverMgr, self.slideshow) + self._loadRoute('details', 'RouteDetails', self.displayMgr, self.driverMgr, + self.colormatch, self.slideshow, self.serviceMgr, self.settingsMgr) + self._loadRoute('upload', 'RouteUpload', self.settingsMgr, self.driverMgr) + self._loadRoute('oauthlink', 'RouteOAuthLink', self.serviceMgr, self.slideshow) + self._loadRoute('service', 'RouteService', self.serviceMgr, self.slideshow) + self._loadRoute('control', 'RouteControl', self.slideshow) + self._loadRoute('events', 'RouteEvents', self.eventMgr) + + def validateSettings(self): + if not self.settingsMgr.load(): + # First run, grab display settings from current mode + current = self.displayMgr.current() + if current is not None: + logging.info('No display settings, using: %s' % repr(current)) + self.settingsMgr.setUser('tvservice', '%s %s HDMI' % (current['mode'], current['code'])) + self.settingsMgr.save() + else: + logging.info('No display attached?') + if self.settingsMgr.getUser('timezone') == '': + self.settingsMgr.setUser('timezone', helper.timezoneCurrent()) + self.settingsMgr.save() + + width, height, tvservice = self.displayMgr.setConfiguration( + self.settingsMgr.getUser('tvservice'), self.settingsMgr.getUser('display-special')) + self.settingsMgr.setUser('tvservice', tvservice) + self.settingsMgr.setUser('width', width) + self.settingsMgr.setUser('height', height) self.settingsMgr.save() - else: - logging.info('No display attached?') - if self.settingsMgr.getUser('timezone') == '': - self.settingsMgr.setUser('timezone', helper.timezoneCurrent()) - self.settingsMgr.save() - - width, height, tvservice = self.displayMgr.setConfiguration(self.settingsMgr.getUser('tvservice'), self.settingsMgr.getUser('display-special')) - self.settingsMgr.setUser('tvservice', tvservice) - self.settingsMgr.setUser('width', width) - self.settingsMgr.setUser('height', height) - self.settingsMgr.save() - - def changeRoot(self, newRoot): - if newRoot is None: return - newpath = os.path.join(newRoot, '/') - logging.info('Altering basedir to %s', newpath) - self.settings().reassignBase(newpath) - - def enableEmulation(self): - logging.info('Running in emulation mode, settings are stored in /tmp/photoframe/') - if not os.path.exists('/tmp/photoframe'): - os.mkdir('/tmp/photoframe') - path().reassignBase('/tmp/photoframe/') - path().reassignConfigTxt('extras/config.txt') - - def start(self): - signal.signal(signal.SIGHUP, lambda x, y: self.updating(x,y)) - self.slideshow.start() - self.webServer.start() + + def changeRoot(self, newRoot): + if newRoot is None: + return + newpath = os.path.join(newRoot, '/') + logging.info('Altering basedir to %s', newpath) + self.settings().reassignBase(newpath) + + def enableEmulation(self): + logging.info('Running in emulation mode, settings are stored in /tmp/photoframe/') + if not os.path.exists('/tmp/photoframe'): + os.mkdir('/tmp/photoframe') + path().reassignBase('/tmp/photoframe/') + path().reassignConfigTxt('extras/config.txt') + + def start(self): + signal.signal(signal.SIGHUP, lambda x, y: self.updating(x, y)) + self.slideshow.start() + self.webServer.start() + frame = Photoframe(cmdline) frame.start() diff --git a/modules/cachemanager.py b/modules/cachemanager.py index b64e5ad..4294f1f 100755 --- a/modules/cachemanager.py +++ b/modules/cachemanager.py @@ -32,167 +32,168 @@ KB = 10**3 MB = KB * 10**3 GB = MB * 10**3 -#NOTE: all values are in Bytes! +# NOTE: all values are in Bytes! ################## + class CacheManager: - STATE_HEAPS = 0 - STATE_ENOUGH = 1 - STATE_WORRISOME = 2 - STATE_CRITICAL = 3 - STATE_FULL = 4 - - def __init__(self): - self.enable = True - - def enableCache(self, enable): - self.enable = enable - logging.info('Cache is set to ' + repr(enable)) - - def validate(self): - self.createDirs() - self.garbageCollect() - - def formatBytes(self, size): - if size > 0.1*GB: - return "%.1fGB" % (float(size)/GB) - elif size > 0.1*MB: - return "%.1fMB" % (float(size)/MB) - elif size > 0.1*KB: - return "%.1fKB" % (float(size)/KB) - return "%dB" % size - - def getCachedImage(self, cacheId, destination): - if not self.enable or cacheId is None: - return None - - filename = os.path.join(syspath.CACHEFOLDER, cacheId) - if os.path.isfile(filename): - try: - shutil.copy(filename, destination) - logging.debug('Cache hit, using %s as %s', cacheId, destination) - return destination - except: - logging.exception('Failed to copy cached image') - return None - - def setCachedImage(self, filename, cacheId): - if not self.enable: - return None - - # Will copy the file if possible, otherwise - # copy/delete it. - cacheFile = os.path.join(syspath.CACHEFOLDER, cacheId) - try: - if os.path.exists(cacheFile): - os.unlink(cacheFile) - shutil.copy(filename, cacheFile) - logging.debug('Cached %s as %s', filename, cacheId) - return filename - except: - logging.exception('Failed to ownership of file') - return None - - def createDirs(self, subDirs=[]): - if not os.path.exists(syspath.CACHEFOLDER): - os.mkdir(syspath.CACHEFOLDER) - for subDir in[os.path.join(syspath.CACHEFOLDER, d) for d in subDirs]: - if not os.path.exists(subDir): - os.mkdir(subDir) - - # delete all files but keep directory structure intact - def empty(self, directory = syspath.CACHEFOLDER): - freedUpSpace = 0 - if not os.path.isdir(directory): - logging.exception('Failed to delete "%s". Directory does not exist!' % directory) - return freedUpSpace - - for p, _dirs, files in os.walk(directory): - for filename in [os.path.join(p, f) for f in files]: - freedUpSpace += os.stat(filename).st_size + STATE_HEAPS = 0 + STATE_ENOUGH = 1 + STATE_WORRISOME = 2 + STATE_CRITICAL = 3 + STATE_FULL = 4 + + def __init__(self): + self.enable = True + + def enableCache(self, enable): + self.enable = enable + logging.info('Cache is set to ' + repr(enable)) + + def validate(self): + self.createDirs() + self.garbageCollect() + + def formatBytes(self, size): + if size > 0.1*GB: + return "%.1fGB" % (float(size)/GB) + elif size > 0.1*MB: + return "%.1fMB" % (float(size)/MB) + elif size > 0.1*KB: + return "%.1fKB" % (float(size)/KB) + return "%dB" % size + + def getCachedImage(self, cacheId, destination): + if not self.enable or cacheId is None: + return None + + filename = os.path.join(syspath.CACHEFOLDER, cacheId) + if os.path.isfile(filename): + try: + shutil.copy(filename, destination) + logging.debug('Cache hit, using %s as %s', cacheId, destination) + return destination + except: + logging.exception('Failed to copy cached image') + return None + + def setCachedImage(self, filename, cacheId): + if not self.enable: + return None + + # Will copy the file if possible, otherwise + # copy/delete it. + cacheFile = os.path.join(syspath.CACHEFOLDER, cacheId) try: - os.unlink(filename) + if os.path.exists(cacheFile): + os.unlink(cacheFile) + shutil.copy(filename, cacheFile) + logging.debug('Cached %s as %s', filename, cacheId) + return filename except: - logging.exception('Failed to delete "%s"' % filename) - logging.info("'%s' has been emptied"%directory) - return freedUpSpace - - - # delete all files that were modified earlier than {minAge} - # return total freedUpSpace in bytes - def deleteOldFiles(self, topPath, minAge): - if topPath is None or topPath == '': - logging.error('deleteOldFiles() called with invalid path') - return 0 - freedUpSpace = 0 - now = time.time() - try: - for path, _dirs, files in os.walk(topPath): - for filename in [os.path.join(path, f) for f in files]: - stat = os.stat(filename) - if stat.st_mtime < now - minAge: - try: - os.remove(filename) - logging.debug("old file '%s' deleted" % filename) - freedUpSpace += stat.st_size - except OSError as e: - logging.warning("unable to delete file '%s'!" % filename) - logging.exception("Output: "+e.strerror) - except: - logging.exception('Failed to clean "%s"', topPath) - return freedUpSpace - - def getDirSize(self, path): - size = 0 - for path, _dirs, files in os.walk(path): - for filename in [os.path.join(path, f) for f in files]: - size += os.stat(filename).st_size - return size - - # classify disk space usage into five differnt states based on free/total ratio - def getDiskSpaceState(self, path): - # all values are in bytes! - #dirSize = float(self.getDirSize(path)) - - stat = os.statvfs(path) - total = float(stat.f_blocks*stat.f_bsize) - free = float(stat.f_bfree*stat.f_bsize) - - #logging.debug("'%s' takes up %s" % (path, CacheManager.formatBytes(dirSize))) - #logging.debug("free space on partition: %s" % CacheManager.formatBytes(free)) - #logging.debug("total space on partition: %s" % CacheManager.formatBytes(total)) - - if free < 50*MB: - return CacheManager.STATE_FULL - elif free/total < 0.1: - return CacheManager.STATE_CRITICAL - elif free/total < 0.2: - return CacheManager.STATE_WORRISOME - elif free/total < 0.5: - return CacheManager.STATE_ENOUGH - else: - return CacheManager.STATE_HEAPS - - # Free up space of any tmp/cache folder - # Frequently calling this function will make sure, less important files are deleted before having to delete more important ones. - # Of course a manual cache reset is possible via the photoframe web interface - def garbageCollect(self, lessImportantDirs=[]): - #logging.debug("Garbage Collector started!") - state = self.getDiskSpaceState(syspath.CACHEFOLDER) - freedUpSpace = 0 - if state == CacheManager.STATE_FULL: - freedUpSpace = self.empty(syspath.CACHEFOLDER) - elif state == CacheManager.STATE_CRITICAL: - for subDir in [os.path.join(syspath.CACHEFOLDER, d) for d in lessImportantDirs]: - freedUpSpace += self.empty(subDir) - elif state == CacheManager.STATE_WORRISOME: - freedUpSpace = self.deleteOldFiles(syspath.CACHEFOLDER, 7*DAY) - elif state == CacheManager.STATE_ENOUGH: - freedUpSpace = self.deleteOldFiles(syspath.CACHEFOLDER, MONTH) - else: - freedUpSpace = self.deleteOldFiles(syspath.CACHEFOLDER, 6*MONTH) - ''' + logging.exception('Failed to ownership of file') + return None + + def createDirs(self, subDirs=[]): + if not os.path.exists(syspath.CACHEFOLDER): + os.mkdir(syspath.CACHEFOLDER) + for subDir in[os.path.join(syspath.CACHEFOLDER, d) for d in subDirs]: + if not os.path.exists(subDir): + os.mkdir(subDir) + + # delete all files but keep directory structure intact + def empty(self, directory=syspath.CACHEFOLDER): + freedUpSpace = 0 + if not os.path.isdir(directory): + logging.exception('Failed to delete "%s". Directory does not exist!' % directory) + return freedUpSpace + + for p, _dirs, files in os.walk(directory): + for filename in [os.path.join(p, f) for f in files]: + freedUpSpace += os.stat(filename).st_size + try: + os.unlink(filename) + except: + logging.exception('Failed to delete "%s"' % filename) + logging.info("'%s' has been emptied" % directory) + return freedUpSpace + + # delete all files that were modified earlier than {minAge} + # return total freedUpSpace in bytes + + def deleteOldFiles(self, topPath, minAge): + if topPath is None or topPath == '': + logging.error('deleteOldFiles() called with invalid path') + return 0 + freedUpSpace = 0 + now = time.time() + try: + for path, _dirs, files in os.walk(topPath): + for filename in [os.path.join(path, f) for f in files]: + stat = os.stat(filename) + if stat.st_mtime < now - minAge: + try: + os.remove(filename) + logging.debug("old file '%s' deleted" % filename) + freedUpSpace += stat.st_size + except OSError as e: + logging.warning("unable to delete file '%s'!" % filename) + logging.exception("Output: "+e.strerror) + except: + logging.exception('Failed to clean "%s"', topPath) + return freedUpSpace + + def getDirSize(self, path): + size = 0 + for path, _dirs, files in os.walk(path): + for filename in [os.path.join(path, f) for f in files]: + size += os.stat(filename).st_size + return size + + # classify disk space usage into five differnt states based on free/total ratio + def getDiskSpaceState(self, path): + # all values are in bytes! + #dirSize = float(self.getDirSize(path)) + + stat = os.statvfs(path) + total = float(stat.f_blocks*stat.f_bsize) + free = float(stat.f_bfree*stat.f_bsize) + + #logging.debug("'%s' takes up %s" % (path, CacheManager.formatBytes(dirSize))) + #logging.debug("free space on partition: %s" % CacheManager.formatBytes(free)) + #logging.debug("total space on partition: %s" % CacheManager.formatBytes(total)) + + if free < 50*MB: + return CacheManager.STATE_FULL + elif free/total < 0.1: + return CacheManager.STATE_CRITICAL + elif free/total < 0.2: + return CacheManager.STATE_WORRISOME + elif free/total < 0.5: + return CacheManager.STATE_ENOUGH + else: + return CacheManager.STATE_HEAPS + + # Free up space of any tmp/cache folder + # Frequently calling this function will make sure, less important files are deleted before having to delete more important ones. + # Of course a manual cache reset is possible via the photoframe web interface + def garbageCollect(self, lessImportantDirs=[]): + #logging.debug("Garbage Collector started!") + state = self.getDiskSpaceState(syspath.CACHEFOLDER) + freedUpSpace = 0 + if state == CacheManager.STATE_FULL: + freedUpSpace = self.empty(syspath.CACHEFOLDER) + elif state == CacheManager.STATE_CRITICAL: + for subDir in [os.path.join(syspath.CACHEFOLDER, d) for d in lessImportantDirs]: + freedUpSpace += self.empty(subDir) + elif state == CacheManager.STATE_WORRISOME: + freedUpSpace = self.deleteOldFiles(syspath.CACHEFOLDER, 7*DAY) + elif state == CacheManager.STATE_ENOUGH: + freedUpSpace = self.deleteOldFiles(syspath.CACHEFOLDER, MONTH) + else: + freedUpSpace = self.deleteOldFiles(syspath.CACHEFOLDER, 6*MONTH) + ''' if freedUpSpace: logging.info("Garbage Collector was able to free up %s of disk space!" % CacheManager.formatBytes(freedUpSpace)) else: diff --git a/modules/colormatch.py b/modules/colormatch.py index 1947bcb..2deeaed 100755 --- a/modules/colormatch.py +++ b/modules/colormatch.py @@ -20,165 +20,168 @@ import subprocess import logging + class colormatch(Thread): - def __init__(self, script, min = None, max = None): - Thread.__init__(self) - self.daemon = True - self.sensor = False - self.temperature = None - self.lux = None - self.script = script - self.void = open(os.devnull, 'wb') - self.min = min - self.max = max - self.listener = None - self.allowAdjust = False - if self.script is not None and self.script != '': - self.hasScript = os.path.exists(self.script) - else: - self.hasScript = False - - self.start() - - def setLimits(self, min, max): - self.min = min - self.max = max - - def hasSensor(self): - return self.sensor - - def hasTemperature(self): - return self.temperature != None - - def hasLux(self): - return self.lux != None - - def getTemperature(self): - return self.temperature - - def getLux(self): - return self.lux - - def setUpdateListener(self, listener): - self.listener = listener - - def adjust(self, filename, filenameTemp, temperature=None): - if not self.allowAdjust or not self.hasScript: - return False - - if self.temperature is None or self.sensor is None: - logging.debug('Temperature is %s and sensor is %s', repr(self.temperature), repr(self.sensor)) - return False - if temperature is None: - temperature = self.temperature - if self.min is not None and temperature < self.min: - logging.debug('Actual color temp measured is %d, but we cap to %dK' % (temperature, self.min)) - temperature = self.min - elif self.max is not None and temperature > self.max: - logging.debug('Actual color temp measured is %d, but we cap to %dK' % (temperature, self.max)) - temperature = self.max - else: - logging.debug('Adjusting color temperature to %dK' % temperature) - - try: - result = subprocess.call([self.script, '-t', "%d" % temperature, filename + '[0]', filenameTemp], stderr=self.void) == 0 - if os.path.exists(filenameTemp + '.cache'): - logging.warning('colormatch called without filename extension, lingering .cache file will stay behind') - - return result - except: - logging.exception('Unable to run %s:', self.script) - return False - - # The following function (_temperature_and_lux) is lifted from the - # https://github.com/adafruit/Adafruit_CircuitPython_TCS34725 project and - # is under MIT license, this license ONLY applies to said function and no - # other part of this project. - # - # The MIT License (MIT) - # - # Copyright (c) 2017 Tony DiCola for Adafruit Industries - # - # Permission is hereby granted, free of charge, to any person obtaining a copy - # of this software and associated documentation files (the "Software"), to deal - # in the Software without restriction, including without limitation the rights - # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - # copies of the Software, and to permit persons to whom the Software is - # furnished to do so, subject to the following conditions: - # - # The above copyright notice and this permission notice shall be included in - # all copies or substantial portions of the Software. - # - # 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. - def _temperature_and_lux(self, data): - """Convert the 4-tuple of raw RGBC data to color temperature and lux values. Will return - 2-tuple of color temperature and lux.""" - r, g, b, _ = data - x = -0.14282 * r + 1.54924 * g + -0.95641 * b - y = -0.32466 * r + 1.57837 * g + -0.73191 * b - z = -0.68202 * r + 0.77073 * g + 0.56332 * b - divisor = x + y + z - n = (x / divisor - 0.3320) / (0.1858 - y / divisor) - cct = 449.0 * n**3 + 3525.0 * n**2 + 6823.3 * n + 5520.33 - return cct, y - ################################################################################### - - # This function is mostly based of the example provided by Brad Berkland's blog: - # http://bradsrpi.blogspot.com/2013/05/tcs34725-rgb-color-sensor-raspberry-pi.html - # - def run(self): - try: - bus = smbus.SMBus(1) - except: - logging.info('No SMB subsystem, color sensor unavailable') - return - # I2C address 0x29 - # Register 0x12 has device ver. - # Register addresses must be OR'ed with 0x80 - try: - bus.write_byte(0x29,0x80|0x12) - except: - logging.info('ColorSensor not available') - return - ver = bus.read_byte(0x29) - # version # should be 0x44 - if ver == 0x44: - # Make sure we have the needed script - if not os.path.exists(self.script): - logging.info('No color temperature script, download it from http://www.fmwconcepts.com/imagemagick/colortemp/index.php and save as "%s"' % self.script) - self.allowAdjust = False - self.allowAdjust = True - - bus.write_byte(0x29, 0x80|0x00) # 0x00 = ENABLE register - bus.write_byte(0x29, 0x01|0x02) # 0x01 = Power on, 0x02 RGB sensors enabled - bus.write_byte(0x29, 0x80|0x14) # Reading results start register 14, LSB then MSB - self.sensor = True - logging.debug('TCS34725 detected, starting polling loop') - while True: - data = bus.read_i2c_block_data(0x29, 0) - clear = clear = data[1] << 8 | data[0] - red = data[3] << 8 | data[2] - green = data[5] << 8 | data[4] - blue = data[7] << 8 | data[6] - if red > 0 and green > 0 and blue > 0 and clear > 0: - temp, lux = self._temperature_and_lux((red, green, blue, clear)) - self.temperature = temp - self.lux = lux - else: - # All zero Happens when no light is available, so set temp to zero - self.temperature = 0 - self.lux = 0 - - if self.listener: - self.listener(self.temperature, self.lux) - - time.sleep(1) - else: - logging.info('No TCS34725 color sensor detected, will not compensate for ambient color temperature') - self.sensor = False + def __init__(self, script, min=None, max=None): + Thread.__init__(self) + self.daemon = True + self.sensor = False + self.temperature = None + self.lux = None + self.script = script + self.void = open(os.devnull, 'wb') + self.min = min + self.max = max + self.listener = None + self.allowAdjust = False + if self.script is not None and self.script != '': + self.hasScript = os.path.exists(self.script) + else: + self.hasScript = False + + self.start() + + def setLimits(self, min, max): + self.min = min + self.max = max + + def hasSensor(self): + return self.sensor + + def hasTemperature(self): + return self.temperature != None + + def hasLux(self): + return self.lux != None + + def getTemperature(self): + return self.temperature + + def getLux(self): + return self.lux + + def setUpdateListener(self, listener): + self.listener = listener + + def adjust(self, filename, filenameTemp, temperature=None): + if not self.allowAdjust or not self.hasScript: + return False + + if self.temperature is None or self.sensor is None: + logging.debug('Temperature is %s and sensor is %s', repr(self.temperature), repr(self.sensor)) + return False + if temperature is None: + temperature = self.temperature + if self.min is not None and temperature < self.min: + logging.debug('Actual color temp measured is %d, but we cap to %dK' % (temperature, self.min)) + temperature = self.min + elif self.max is not None and temperature > self.max: + logging.debug('Actual color temp measured is %d, but we cap to %dK' % (temperature, self.max)) + temperature = self.max + else: + logging.debug('Adjusting color temperature to %dK' % temperature) + + try: + result = subprocess.call([self.script, '-t', "%d" % temperature, filename + + '[0]', filenameTemp], stderr=self.void) == 0 + if os.path.exists(filenameTemp + '.cache'): + logging.warning('colormatch called without filename extension, lingering .cache file will stay behind') + + return result + except: + logging.exception('Unable to run %s:', self.script) + return False + + # The following function (_temperature_and_lux) is lifted from the + # https://github.com/adafruit/Adafruit_CircuitPython_TCS34725 project and + # is under MIT license, this license ONLY applies to said function and no + # other part of this project. + # + # The MIT License (MIT) + # + # Copyright (c) 2017 Tony DiCola for Adafruit Industries + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in + # all copies or substantial portions of the Software. + # + # 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. + def _temperature_and_lux(self, data): + """Convert the 4-tuple of raw RGBC data to color temperature and lux values. Will return + 2-tuple of color temperature and lux.""" + r, g, b, _ = data + x = -0.14282 * r + 1.54924 * g + -0.95641 * b + y = -0.32466 * r + 1.57837 * g + -0.73191 * b + z = -0.68202 * r + 0.77073 * g + 0.56332 * b + divisor = x + y + z + n = (x / divisor - 0.3320) / (0.1858 - y / divisor) + cct = 449.0 * n**3 + 3525.0 * n**2 + 6823.3 * n + 5520.33 + return cct, y + ################################################################################### + + # This function is mostly based of the example provided by Brad Berkland's blog: + # http://bradsrpi.blogspot.com/2013/05/tcs34725-rgb-color-sensor-raspberry-pi.html + # + def run(self): + try: + bus = smbus.SMBus(1) + except: + logging.info('No SMB subsystem, color sensor unavailable') + return + # I2C address 0x29 + # Register 0x12 has device ver. + # Register addresses must be OR'ed with 0x80 + try: + bus.write_byte(0x29, 0x80 | 0x12) + except: + logging.info('ColorSensor not available') + return + ver = bus.read_byte(0x29) + # version # should be 0x44 + if ver == 0x44: + # Make sure we have the needed script + if not os.path.exists(self.script): + logging.info( + 'No color temperature script, download it from http://www.fmwconcepts.com/imagemagick/colortemp/index.php and save as "%s"' % self.script) + self.allowAdjust = False + self.allowAdjust = True + + bus.write_byte(0x29, 0x80 | 0x00) # 0x00 = ENABLE register + bus.write_byte(0x29, 0x01 | 0x02) # 0x01 = Power on, 0x02 RGB sensors enabled + bus.write_byte(0x29, 0x80 | 0x14) # Reading results start register 14, LSB then MSB + self.sensor = True + logging.debug('TCS34725 detected, starting polling loop') + while True: + data = bus.read_i2c_block_data(0x29, 0) + clear = clear = data[1] << 8 | data[0] + red = data[3] << 8 | data[2] + green = data[5] << 8 | data[4] + blue = data[7] << 8 | data[6] + if red > 0 and green > 0 and blue > 0 and clear > 0: + temp, lux = self._temperature_and_lux((red, green, blue, clear)) + self.temperature = temp + self.lux = lux + else: + # All zero Happens when no light is available, so set temp to zero + self.temperature = 0 + self.lux = 0 + + if self.listener: + self.listener(self.temperature, self.lux) + + time.sleep(1) + else: + logging.info('No TCS34725 color sensor detected, will not compensate for ambient color temperature') + self.sensor = False diff --git a/modules/debug.py b/modules/debug.py index 48f8cb4..0f67ed9 100755 --- a/modules/debug.py +++ b/modules/debug.py @@ -19,54 +19,60 @@ import sys import traceback + def _stringify(args): - result = '' - if len(args) > 0: - for arg in args: - if ' ' in arg: - result += '"' + arg + '" ' - else: - result += arg + ' ' - result = result[0:-1] + result = '' + if len(args) > 0: + for arg in args: + if ' ' in arg: + result += '"' + arg + '" ' + else: + result += arg + ' ' + result = result[0:-1] + + return result.replace('\n', '\\n') - return result.replace('\n', '\\n') def subprocess_call(cmds, stderr=None, stdout=None): - #logging.debug('subprocess.call(%s)', _stringify(cmds)) - return subprocess.call(cmds, stderr=stderr, stdout=stdout) + #logging.debug('subprocess.call(%s)', _stringify(cmds)) + return subprocess.call(cmds, stderr=stderr, stdout=stdout) + def subprocess_check_output(cmds, stderr=None): - #logging.debug('subprocess.check_output(%s)', _stringify(cmds)) - return subprocess.check_output(cmds, stderr=stderr) + #logging.debug('subprocess.check_output(%s)', _stringify(cmds)) + return subprocess.check_output(cmds, stderr=stderr) + def stacktrace(): - title = 'Stacktrace of all running threads' - lines = [] - for threadId, stack in list(sys._current_frames().items()): - lines.append("\n# ThreadID: %s" % threadId) - for filename, lineno, name, line in traceback.extract_stack(stack): - lines.append('File: "%s", line %d, in %s' % (filename, lineno, name)) - if line: - lines.append(" %s" % (line.strip())) - return (title, lines, None) + title = 'Stacktrace of all running threads' + lines = [] + for threadId, stack in list(sys._current_frames().items()): + lines.append("\n# ThreadID: %s" % threadId) + for filename, lineno, name, line in traceback.extract_stack(stack): + lines.append('File: "%s", line %d, in %s' % (filename, lineno, name)) + if line: + lines.append(" %s" % (line.strip())) + return (title, lines, None) + def logfile(all=False): - stats = os.stat('/var/log/syslog') - cmd = 'grep -a "photoframe\[" /var/log/syslog | tail -n 100' - title = 'Last 100 lines from the photoframe log' - if all: - title = 'Last 100 lines from the system log (/var/log/syslog)' - cmd = 'tail -n 100 /var/log/syslog' - lines = subprocess.check_output(cmd, shell=True) - if lines: - lines = lines.splitlines() - suffix = '(size of logfile %d bytes, created %s)' % (stats.st_size, datetime.datetime.fromtimestamp(stats.st_ctime).strftime('%c')) - return (title, lines, suffix) + stats = os.stat('/var/log/syslog') + cmd = 'grep -a "photoframe\[" /var/log/syslog | tail -n 100' + title = 'Last 100 lines from the photoframe log' + if all: + title = 'Last 100 lines from the system log (/var/log/syslog)' + cmd = 'tail -n 100 /var/log/syslog' + lines = subprocess.check_output(cmd, shell=True) + if lines: + lines = lines.splitlines() + suffix = '(size of logfile %d bytes, created %s)' % (stats.st_size, + datetime.datetime.fromtimestamp(stats.st_ctime).strftime('%c')) + return (title, lines, suffix) def version(): - title = 'Running version' - lines = subprocess.check_output('git log HEAD~1..HEAD ; echo "" ; git status', shell=True) - if lines: - lines = lines.splitlines() - return (title, lines, None) + title = 'Running version' + lines = subprocess.check_output('git log HEAD~1..HEAD ; echo "" ; git status', shell=True) + if lines: + lines = lines.splitlines() + return (title, lines, None) diff --git a/modules/dedupe.py b/modules/dedupe.py index c857457..b4a3df5 100755 --- a/modules/dedupe.py +++ b/modules/dedupe.py @@ -15,31 +15,32 @@ # import logging + class DedupeManager: - def __init__(self, memoryLocation): - try: - #from PIL import Image - #import imagehash - self.hasImageHash = True - logging.info('ImageHash functionality is available') - except: - self.hasImageHash = False - logging.info('ImageHash functionality is unavailable') + def __init__(self, memoryLocation): + try: + #from PIL import Image + #import imagehash + self.hasImageHash = True + logging.info('ImageHash functionality is available') + except: + self.hasImageHash = False + logging.info('ImageHash functionality is unavailable') - def _hamming_distance(self, i1, i2): - x = i1 ^ i2 - setBits = 0 + def _hamming_distance(self, i1, i2): + x = i1 ^ i2 + setBits = 0 - while (x > 0): - setBits += x & 1 - x >>= 1 + while (x > 0): + setBits += x & 1 + x >>= 1 - return setBits + return setBits - def _hamming(self, s1, s2): - h = 0 - for i in range(0, len(s1)/2): - i1 = int(s1[i*2:i*2+2], 16) - i2 = int(s2[i*2:i*2+2], 16) - h += self._hamming_distance(i1, i2) - return h + def _hamming(self, s1, s2): + h = 0 + for i in range(0, len(s1)/2): + i1 = int(s1[i*2:i*2+2], 16) + i2 = int(s2[i*2:i*2+2], 16) + h += self._hamming_distance(i1, i2) + return h diff --git a/modules/display.py b/modules/display.py index 9ffaa3f..e8a86f9 100755 --- a/modules/display.py +++ b/modules/display.py @@ -24,386 +24,393 @@ from .sysconfig import sysconfig from .helper import helper + class display: - def __init__(self, use_emulator=False, emulate_width=1280, emulate_height=720): - self.void = open(os.devnull, 'wb') - self.params = None - self.special = None - self.emulate = use_emulator - self.emulate_width = emulate_width - self.emulate_height = emulate_height - self.rotated = sysconfig.isDisplayRotated() - self.xoffset = 0 - self.yoffset = 0 - self.url = None - if self.emulate: - logging.info('Using framebuffer emulation') - self.lastMessage = None - - def setConfigPage(self, url): - self.url = url - - def setConfiguration(self, tvservice_params, special=None): - self.enabled = True - - # Erase old picture - if self.params is not None: - self.clear() - - if self.emulate: - self.width = self.emulate_width - self.height = self.emulate_height - self.depth = 32 - self.reverse = False - self.format = 'rgba' - self.params = None - self.special = None - return (self.width, self.height, '') - - result = display.validate(tvservice_params, special) - if result is None: - logging.error('Unable to find a valid display mode, will default to 1280x720') - # TODO: THis is less than ideal, maybe we should fetch resolution from fbset instead? - # but then we should also avoid touching the display since it will cause issues. - self.enabled = False - self.params = None - self.special = None - return (1280, 720, '') - - self.width = result['width'] - self.height = result['height'] - self.pwidth = self.width - self.pheight = self.height - - if self.rotated: - # Calculate offset for X, must be even dividable with 16 - self.xoffset = (16 - (self.height % 16)) % 16 - self.width = self.pheight - self.height = self.pwidth - - self.depth = result['depth'] - self.reverse = result['reverse'] - self.params = result['tvservice'] - if self.reverse: - self.format = 'bgr' - else: - self.format = 'rgb' - if self.depth == 32: - self.format += 'a' - - return (self.width, self.height, self.params) - - def getDevice(self): - if self.params and self.params.split(' ')[0] == 'INTERNAL': - device = '/dev/fb' + self.params.split(' ')[1] - if os.path.exists(device): - return device - return '/dev/fb0' - - def isHDMI(self): - return self.getDevice() == '/dev/fb0' and not display._isDPI() - - def get(self): - if self.enabled: - args = [ - 'convert', - '-depth', - '8', - '-size', - '%dx%d' % (self.width+self.xoffset, self.height+self.yoffset), - '%s:-' % (self.format), - 'jpg:-' - ] - else: - args = [ - 'convert', - '-size', - '%dx%d' % (640, 360), - '-background', - 'black', - '-fill', - 'white', - '-gravity', - 'center', - '-weight', - '700', - '-pointsize', - '32', - 'label:%s' % "Display off", - '-depth', - '8', - 'jpg:-' - ] - - if not self.enabled: - result = debug.subprocess_check_output(args, stderr=self.void) - elif self.depth in [24, 32]: - device = self.getDevice() - if self.emulate: - device = '/tmp/fb.bin' - with open(device, 'rb') as fb: - pip = subprocess.Popen(args, stdin=fb, stdout=subprocess.PIPE, stderr=self.void) - result = pip.communicate()[0] - elif self.depth == 16: - with open(self.getDevice(), 'rb') as fb: - src = subprocess.Popen(['/root/photoframe/rgb565/rgb565', 'reverse'], stdout=subprocess.PIPE, stdin=fb, stderr=self.void) - pip = subprocess.Popen(args, stdin=src.stdout, stdout=subprocess.PIPE) - src.stdout.close() - result = pip.communicate()[0] - else: - logging.error('Do not know how to grab this kind of framebuffer') - return (result, 'image/jpeg') - - def _to_display(self, arguments): - device = self.getDevice() - if self.emulate: - device = '/tmp/fb.bin' - self.depth = 32 - - if self.depth in [24, 32]: - with open(device, 'wb') as f: - debug.subprocess_call(arguments, stdout=f, stderr=self.void) - elif self.depth == 16: # Typically RGB565 - # For some odd reason, cannot pipe the output directly to the framebuffer, use temp file - with open(device, 'wb') as fb: - src = subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=self.void) - pip = subprocess.Popen(['/root/photoframe/rgb565/rgb565'], stdin=src.stdout, stdout=fb) - src.stdout.close() - pip.communicate() - else: - logging.error('Do not know how to render this, depth is %d', self.depth) - - self.lastMessage = None - - def message(self, message, showConfig=True): - if not self.enabled: - logging.debug('Don\'t bother, display is off') - return - - url = 'caption:' - if helper.getDeviceIp() is not None and showConfig: - url = 'caption:Configuration available at http://%s:7777' % helper.getDeviceIp() - - args = [ - 'convert', - '-size', - '%dx%d' % (self.width, self.height), - '-background', - 'black', - '-fill', - 'white', - '-gravity', - 'center', - '-weight', - '700', - '-pointsize', - '32', - 'caption:%s' % message, - '-background', - 'none', - '-gravity', - 'south', - '-fill', - '#666666', - url, - '-flatten', - '-extent', - '%dx%d+%d+%d' % (self.width + self.xoffset, self.height + self.yoffset, self.xoffset, self.yoffset), - '-depth', - '8', - '%s:-' % self.format - ] - - if self.lastMessage != message: - self._to_display(args) - self.lastMessage = message - - def image(self, filename): - if not self.enabled: - logging.debug('Don\'t bother, display is off') - return - - logging.debug('Showing image to user') - args = [ - 'convert', - filename + '[0]', - '-resize', - '%dx%d' % (self.width, self.height), - '-background', - 'black', - '-gravity', - 'center', - '-extent', - '%dx%d+%d+%d' % (self.width + self.xoffset, self.height + self.yoffset, self.xoffset, self.yoffset), - '-depth', - '8', - '%s:-' % self.format - ] - self._to_display(args) - - def enable(self, enable, force=False): - if enable == self.enabled and not force: - return - - # Do not do things if we don't know how to display - if self.params is None: - return - - if enable: - if self.isHDMI(): - if force: # Make sure display is ON and set to our preference - if os.path.exists('/opt/vc/bin/tvservice'): - debug.subprocess_call(['/opt/vc/bin/tvservice', '-e', self.params], stderr=self.void, stdout=self.void) - time.sleep(1) - debug.subprocess_call(['/bin/fbset', '-fb', self.getDevice(), '-depth', '8'], stderr=self.void) - debug.subprocess_call(['/bin/fbset', '-fb', self.getDevice(), '-depth', str(self.depth), '-xres', str(self.width), '-yres', str(self.height), '-vxres', str(self.width), '-vyres', str(self.height)], stderr=self.void) + def __init__(self, use_emulator=False, emulate_width=1280, emulate_height=720): + self.void = open(os.devnull, 'wb') + self.params = None + self.special = None + self.emulate = use_emulator + self.emulate_width = emulate_width + self.emulate_height = emulate_height + self.rotated = sysconfig.isDisplayRotated() + self.xoffset = 0 + self.yoffset = 0 + self.url = None + if self.emulate: + logging.info('Using framebuffer emulation') + self.lastMessage = None + + def setConfigPage(self, url): + self.url = url + + def setConfiguration(self, tvservice_params, special=None): + self.enabled = True + + # Erase old picture + if self.params is not None: + self.clear() + + if self.emulate: + self.width = self.emulate_width + self.height = self.emulate_height + self.depth = 32 + self.reverse = False + self.format = 'rgba' + self.params = None + self.special = None + return (self.width, self.height, '') + + result = display.validate(tvservice_params, special) + if result is None: + logging.error('Unable to find a valid display mode, will default to 1280x720') + # TODO: THis is less than ideal, maybe we should fetch resolution from fbset instead? + # but then we should also avoid touching the display since it will cause issues. + self.enabled = False + self.params = None + self.special = None + return (1280, 720, '') + + self.width = result['width'] + self.height = result['height'] + self.pwidth = self.width + self.pheight = self.height + + if self.rotated: + # Calculate offset for X, must be even dividable with 16 + self.xoffset = (16 - (self.height % 16)) % 16 + self.width = self.pheight + self.height = self.pwidth + + self.depth = result['depth'] + self.reverse = result['reverse'] + self.params = result['tvservice'] + if self.reverse: + self.format = 'bgr' else: - debug.subprocess_call(['/usr/bin/vcgencmd', 'display_power', '1'], stderr=self.void) - else: - self.clear() - if self.isHDMI(): - debug.subprocess_call(['/usr/bin/vcgencmd', 'display_power', '0'], stderr=self.void) - self.enabled = enable - - def isEnabled(self): - return self.enabled - - def clear(self): - if self.emulate: - self.message('') - return - with open(self.getDevice(), 'wb') as f: - debug.subprocess_call(['cat' , '/dev/zero'], stdout=f, stderr=self.void) - - @staticmethod - def _isDPI(): - if os.path.exists('/opt/vc/bin/tvservice'): - output = debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-s'], stderr=subprocess.STDOUT) - else: - output = '' - return '[LCD]' in output - - @staticmethod - def _internaldisplay(): - entry = { - 'mode' : 'INTERNAL', - 'code' : None, - 'width' : 0, - 'height' : 0, - 'rate' : 60, - 'aspect_ratio' : '', - 'scan' : '(internal)', - '3d_modes' : [], - 'reverse' : False - } - device = '/dev/fb1' - if not os.path.exists(device): - if display._isDPI(): - device = '/dev/fb0' - else: - device = None - if device: - info = debug.subprocess_check_output(['/bin/fbset', '-fb', device], stderr=subprocess.STDOUT).split('\n') - for line in info: - line = line.strip() - if line.startswith('geometry'): - parts = line.split(' ') - entry['width'] = int(parts[1]) - entry['height'] = int(parts[2]) - entry['depth'] = int(parts[5]) - entry['code'] = int(device[-1]) - # rgba 8/16,8/8,8/0,8/24 <== Detect rgba order - if line.startswith('rgba'): - m = re.search('rgba [0-9]*/([0-9]*),[0-9]*/([0-9]*),[0-9]*/([0-9]*),[0-9]*/([0-9]*)', line) - if m is None: - logging.error('fbset output has changed, cannot parse') - return None - entry['reverse'] = m.group(1) != 0 - if entry['code'] is not None: - logging.debug('Internal display: ' + repr(entry)) - return entry - return None - - def current(self): - result = None - if self.isHDMI() and os.path.exists('/opt/vc/bin/tvservice'): - output = debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-s'], stderr=subprocess.STDOUT) - # state 0x120006 [DVI DMT (82) RGB full 16:9], 1920x1080 @ 60.00Hz, progressive - m = re.search('state 0x[0-9a-f]* \[([A-Z]*) ([A-Z]*) \(([0-9]*)\) [^,]*, ([0-9]*)x([0-9]*) \@ ([0-9]*)\.[0-9]*Hz, (.)', output) - if m is None: + self.format = 'rgb' + if self.depth == 32: + self.format += 'a' + + return (self.width, self.height, self.params) + + def getDevice(self): + if self.params and self.params.split(' ')[0] == 'INTERNAL': + device = '/dev/fb' + self.params.split(' ')[1] + if os.path.exists(device): + return device + return '/dev/fb0' + + def isHDMI(self): + return self.getDevice() == '/dev/fb0' and not display._isDPI() + + def get(self): + if self.enabled: + args = [ + 'convert', + '-depth', + '8', + '-size', + '%dx%d' % (self.width+self.xoffset, self.height+self.yoffset), + '%s:-' % (self.format), + 'jpg:-' + ] + else: + args = [ + 'convert', + '-size', + '%dx%d' % (640, 360), + '-background', + 'black', + '-fill', + 'white', + '-gravity', + 'center', + '-weight', + '700', + '-pointsize', + '32', + 'label:%s' % "Display off", + '-depth', + '8', + 'jpg:-' + ] + + if not self.enabled: + result = debug.subprocess_check_output(args, stderr=self.void) + elif self.depth in [24, 32]: + device = self.getDevice() + if self.emulate: + device = '/tmp/fb.bin' + with open(device, 'rb') as fb: + pip = subprocess.Popen(args, stdin=fb, stdout=subprocess.PIPE, stderr=self.void) + result = pip.communicate()[0] + elif self.depth == 16: + with open(self.getDevice(), 'rb') as fb: + src = subprocess.Popen(['/root/photoframe/rgb565/rgb565', 'reverse'], + stdout=subprocess.PIPE, stdin=fb, stderr=self.void) + pip = subprocess.Popen(args, stdin=src.stdout, stdout=subprocess.PIPE) + src.stdout.close() + result = pip.communicate()[0] + else: + logging.error('Do not know how to grab this kind of framebuffer') + return (result, 'image/jpeg') + + def _to_display(self, arguments): + device = self.getDevice() + if self.emulate: + device = '/tmp/fb.bin' + self.depth = 32 + + if self.depth in [24, 32]: + with open(device, 'wb') as f: + debug.subprocess_call(arguments, stdout=f, stderr=self.void) + elif self.depth == 16: # Typically RGB565 + # For some odd reason, cannot pipe the output directly to the framebuffer, use temp file + with open(device, 'wb') as fb: + src = subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=self.void) + pip = subprocess.Popen(['/root/photoframe/rgb565/rgb565'], stdin=src.stdout, stdout=fb) + src.stdout.close() + pip.communicate() + else: + logging.error('Do not know how to render this, depth is %d', self.depth) + + self.lastMessage = None + + def message(self, message, showConfig=True): + if not self.enabled: + logging.debug('Don\'t bother, display is off') + return + + url = 'caption:' + if helper.getDeviceIp() is not None and showConfig: + url = 'caption:Configuration available at http://%s:7777' % helper.getDeviceIp() + + args = [ + 'convert', + '-size', + '%dx%d' % (self.width, self.height), + '-background', + 'black', + '-fill', + 'white', + '-gravity', + 'center', + '-weight', + '700', + '-pointsize', + '32', + 'caption:%s' % message, + '-background', + 'none', + '-gravity', + 'south', + '-fill', + '#666666', + url, + '-flatten', + '-extent', + '%dx%d+%d+%d' % (self.width + self.xoffset, self.height + self.yoffset, self.xoffset, self.yoffset), + '-depth', + '8', + '%s:-' % self.format + ] + + if self.lastMessage != message: + self._to_display(args) + self.lastMessage = message + + def image(self, filename): + if not self.enabled: + logging.debug('Don\'t bother, display is off') + return + + logging.debug('Showing image to user') + args = [ + 'convert', + filename + '[0]', + '-resize', + '%dx%d' % (self.width, self.height), + '-background', + 'black', + '-gravity', + 'center', + '-extent', + '%dx%d+%d+%d' % (self.width + self.xoffset, self.height + self.yoffset, self.xoffset, self.yoffset), + '-depth', + '8', + '%s:-' % self.format + ] + self._to_display(args) + + def enable(self, enable, force=False): + if enable == self.enabled and not force: + return + + # Do not do things if we don't know how to display + if self.params is None: + return + + if enable: + if self.isHDMI(): + if force: # Make sure display is ON and set to our preference + if os.path.exists('/opt/vc/bin/tvservice'): + debug.subprocess_call(['/opt/vc/bin/tvservice', '-e', self.params], + stderr=self.void, stdout=self.void) + time.sleep(1) + debug.subprocess_call(['/bin/fbset', '-fb', self.getDevice(), '-depth', '8'], stderr=self.void) + debug.subprocess_call(['/bin/fbset', '-fb', self.getDevice(), '-depth', str(self.depth), '-xres', str( + self.width), '-yres', str(self.height), '-vxres', str(self.width), '-vyres', str(self.height)], stderr=self.void) + else: + debug.subprocess_call(['/usr/bin/vcgencmd', 'display_power', '1'], stderr=self.void) + else: + self.clear() + if self.isHDMI(): + debug.subprocess_call(['/usr/bin/vcgencmd', 'display_power', '0'], stderr=self.void) + self.enabled = enable + + def isEnabled(self): + return self.enabled + + def clear(self): + if self.emulate: + self.message('') + return + with open(self.getDevice(), 'wb') as f: + debug.subprocess_call(['cat', '/dev/zero'], stdout=f, stderr=self.void) + + @staticmethod + def _isDPI(): + if os.path.exists('/opt/vc/bin/tvservice'): + output = debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-s'], stderr=subprocess.STDOUT) + else: + output = '' + return '[LCD]' in output + + @staticmethod + def _internaldisplay(): + entry = { + 'mode': 'INTERNAL', + 'code': None, + 'width': 0, + 'height': 0, + 'rate': 60, + 'aspect_ratio': '', + 'scan': '(internal)', + '3d_modes': [], + 'reverse': False + } + device = '/dev/fb1' + if not os.path.exists(device): + if display._isDPI(): + device = '/dev/fb0' + else: + device = None + if device: + info = debug.subprocess_check_output(['/bin/fbset', '-fb', device], stderr=subprocess.STDOUT).split('\n') + for line in info: + line = line.strip() + if line.startswith('geometry'): + parts = line.split(' ') + entry['width'] = int(parts[1]) + entry['height'] = int(parts[2]) + entry['depth'] = int(parts[5]) + entry['code'] = int(device[-1]) + # rgba 8/16,8/8,8/0,8/24 <== Detect rgba order + if line.startswith('rgba'): + m = re.search('rgba [0-9]*/([0-9]*),[0-9]*/([0-9]*),[0-9]*/([0-9]*),[0-9]*/([0-9]*)', line) + if m is None: + logging.error('fbset output has changed, cannot parse') + return None + entry['reverse'] = m.group(1) != 0 + if entry['code'] is not None: + logging.debug('Internal display: ' + repr(entry)) + return entry return None - result = { - 'mode' : m.group(2), - 'code' : int(m.group(3)), - 'width' : int(m.group(4)), - 'height' : int(m.group(5)), - 'rate' : int(m.group(6)), - 'aspect_ratio' : '', - 'scan' : m.group(7), - '3d_modes' : [], - 'depth':32, - 'reverse':True, - } - else: - result = display._internaldisplay() - - return result - - @staticmethod - def available(): - if os.path.exists('/opt/vc/bin/tvservice'): - cea = json.loads(debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-j', '-m', 'CEA'], stderr=subprocess.STDOUT)) - dmt = json.loads(debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-j', '-m', 'DMT'], stderr=subprocess.STDOUT)) - else: - logging.error('/opt/vc/bin/tvservice is missing! No HDMI resolutions will be available') - cea = [] - dmt = [] - result = [] - for entry in cea: - entry['mode'] = 'CEA' - entry['depth'] = 32 - entry['reverse'] = True - result.append(entry) - for entry in dmt: - entry['mode'] = 'DMT' - entry['depth'] = 32 - entry['reverse'] = True - result.append(entry) - - internal = display._internaldisplay() - if internal: - logging.info('Internal display detected') - result.append(internal) - - # Finally, sort by pixelcount - return sorted(result, key=lambda k: k['width']*k['height']) - - @staticmethod - def validate(tvservice, special): - # Takes a string and returns valid width, height, depth and service - items = tvservice.split(' ') - resolutions = display.available() - if len(resolutions) == 0: - return None - - res = resolutions[0] - if len(items) == 3: - for res in resolutions: - if res['code'] == int(items[1]) and res['mode'] == items[0]: - break - else: - logging.warning('Invalid tvservice data, using first available instead') - - result = { - 'width':res['width'], - 'height':res['height'], - 'depth':res['depth'], - 'reverse':res['reverse'], - 'tvservice':'%s %s %s' % (res['mode'], res['code'], 'HDMI') - } - - # Allow items to be overriden - if special and 'INTERNAL' in result['tvservice']: - if 'reverse' in special: - result['reverse'] = special['reverse'] - return result + + def current(self): + result = None + if self.isHDMI() and os.path.exists('/opt/vc/bin/tvservice'): + output = debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-s'], stderr=subprocess.STDOUT) + # state 0x120006 [DVI DMT (82) RGB full 16:9], 1920x1080 @ 60.00Hz, progressive + m = re.search( + 'state 0x[0-9a-f]* \[([A-Z]*) ([A-Z]*) \(([0-9]*)\) [^,]*, ([0-9]*)x([0-9]*) \@ ([0-9]*)\.[0-9]*Hz, (.)', output) + if m is None: + return None + result = { + 'mode': m.group(2), + 'code': int(m.group(3)), + 'width': int(m.group(4)), + 'height': int(m.group(5)), + 'rate': int(m.group(6)), + 'aspect_ratio': '', + 'scan': m.group(7), + '3d_modes': [], + 'depth': 32, + 'reverse': True, + } + else: + result = display._internaldisplay() + + return result + + @staticmethod + def available(): + if os.path.exists('/opt/vc/bin/tvservice'): + cea = json.loads(debug.subprocess_check_output( + ['/opt/vc/bin/tvservice', '-j', '-m', 'CEA'], stderr=subprocess.STDOUT)) + dmt = json.loads(debug.subprocess_check_output( + ['/opt/vc/bin/tvservice', '-j', '-m', 'DMT'], stderr=subprocess.STDOUT)) + else: + logging.error('/opt/vc/bin/tvservice is missing! No HDMI resolutions will be available') + cea = [] + dmt = [] + result = [] + for entry in cea: + entry['mode'] = 'CEA' + entry['depth'] = 32 + entry['reverse'] = True + result.append(entry) + for entry in dmt: + entry['mode'] = 'DMT' + entry['depth'] = 32 + entry['reverse'] = True + result.append(entry) + + internal = display._internaldisplay() + if internal: + logging.info('Internal display detected') + result.append(internal) + + # Finally, sort by pixelcount + return sorted(result, key=lambda k: k['width']*k['height']) + + @staticmethod + def validate(tvservice, special): + # Takes a string and returns valid width, height, depth and service + items = tvservice.split(' ') + resolutions = display.available() + if len(resolutions) == 0: + return None + + res = resolutions[0] + if len(items) == 3: + for res in resolutions: + if res['code'] == int(items[1]) and res['mode'] == items[0]: + break + else: + logging.warning('Invalid tvservice data, using first available instead') + + result = { + 'width': res['width'], + 'height': res['height'], + 'depth': res['depth'], + 'reverse': res['reverse'], + 'tvservice': '%s %s %s' % (res['mode'], res['code'], 'HDMI') + } + + # Allow items to be overriden + if special and 'INTERNAL' in result['tvservice']: + if 'reverse' in special: + result['reverse'] = special['reverse'] + return result diff --git a/modules/drivers.py b/modules/drivers.py index d3ad88f..9656dc0 100644 --- a/modules/drivers.py +++ b/modules/drivers.py @@ -22,266 +22,271 @@ from modules.path import path + class drivers: - MARKER = '### DO NOT EDIT BEYOND THIS COMMENT, IT\'S AUTOGENERATED BY PHOTOFRAME ###' + MARKER = '### DO NOT EDIT BEYOND THIS COMMENT, IT\'S AUTOGENERATED BY PHOTOFRAME ###' - def __init__(self): - self.void = open(os.devnull, 'wb') - if not os.path.exists(path.DRV_EXTERNAL): - try: - os.mkdir(path.DRV_EXTERNAL) - except: - logging.exception('Unable to create "%s"', path.DRV_EXTERNAL) + def __init__(self): + self.void = open(os.devnull, 'wb') + if not os.path.exists(path.DRV_EXTERNAL): + try: + os.mkdir(path.DRV_EXTERNAL) + except: + logging.exception('Unable to create "%s"', path.DRV_EXTERNAL) - def _list_dir(self, path): - result = {} - contents = os.listdir(path) - for entry in contents: - if not os.path.isdir(os.path.join(path, entry)): - continue - result[entry] = os.path.join(path, entry) - return result + def _list_dir(self, path): + result = {} + contents = os.listdir(path) + for entry in contents: + if not os.path.isdir(os.path.join(path, entry)): + continue + result[entry] = os.path.join(path, entry) + return result - def list(self): - result = {} - if os.path.exists(path.DRV_BUILTIN): - result = self._list_dir(path.DRV_BUILTIN) - # Any driver defined in external that has the same name as an internal one - # will replace the internal one. - result.update(self._list_dir(path.DRV_EXTERNAL)) + def list(self): + result = {} + if os.path.exists(path.DRV_BUILTIN): + result = self._list_dir(path.DRV_BUILTIN) + # Any driver defined in external that has the same name as an internal one + # will replace the internal one. + result.update(self._list_dir(path.DRV_EXTERNAL)) - return result + return result - def _find(self, filename, basedir): - for root, dirnames, filenames in os.walk(basedir): - for filename in filenames: - if filename == 'INSTALL': - return os.path.join(root, filename) - return None + def _find(self, filename, basedir): + for root, dirnames, filenames in os.walk(basedir): + for filename in filenames: + if filename == 'INSTALL': + return os.path.join(root, filename) + return None - def _deletefolder(self, folder): - try: - shutil.rmtree(folder) - except: - logging.exception('Failed to delete "%s"', folder) + def _deletefolder(self, folder): + try: + shutil.rmtree(folder) + except: + logging.exception('Failed to delete "%s"', folder) - def _parse(self, installer): - root = os.path.dirname(installer) - config = {'version':2,'driver' : os.path.basename(root), 'install' : []} - state = 0 - lc = 0 - try: - with open(installer, 'r') as f: - for line in f: - lc += 1 - line = line.strip() - if line.startswith('#') or len(line) == 0: - continue - if line.lower() == '[install]': - state = 1 - continue - if line.lower() == '[config]': - config['config'] = [] - state = 2 - continue - if line.lower() == '[options]': - config['options'] = {} - state = 3 - continue - if state == 1: - src, dst = line.split('=', 1) - src = src.strip() - dst = dst.strip() - if dst == '' or src == '': - logging.error('Install section cannot have an empty source or destination filename (Line %d)', lc) - return None - if '..' in src or src.startswith('/'): - logging.error('Install section must use files within package (Line %d)', lc) - return None - src = os.path.join(root, src) - if not os.path.exists(src): - logging.error('INSTALL manifest points to non-existant file (Line %d)', lc) - return None - config['install'].append({'src':src, 'dst':dst}) - elif state == 2: - if line != '': - config['config'].append(line) - elif state == 3: - key, value = line.split('=', 1) - key = key.strip() - value = value.strip() - if key == '' or value == '': - logging.error('Options section cannot have an empty key or value (Line %d)', lc) - return None - if key in config['options']: - logging.warning('Key "%s" will be overridden since it is defined multiple times (Line %d)', lc) - if value.lower() in ['true', 'yes']: - value = True - elif value.lower() in ['false', 'no']: - value = False - config['options'][key] = value - except: - logging.exception('Failed to read INSTALL manifest') - return None + def _parse(self, installer): + root = os.path.dirname(installer) + config = {'version': 2, 'driver': os.path.basename(root), 'install': []} + state = 0 + lc = 0 + try: + with open(installer, 'r') as f: + for line in f: + lc += 1 + line = line.strip() + if line.startswith('#') or len(line) == 0: + continue + if line.lower() == '[install]': + state = 1 + continue + if line.lower() == '[config]': + config['config'] = [] + state = 2 + continue + if line.lower() == '[options]': + config['options'] = {} + state = 3 + continue + if state == 1: + src, dst = line.split('=', 1) + src = src.strip() + dst = dst.strip() + if dst == '' or src == '': + logging.error( + 'Install section cannot have an empty source or destination filename (Line %d)', lc) + return None + if '..' in src or src.startswith('/'): + logging.error('Install section must use files within package (Line %d)', lc) + return None + src = os.path.join(root, src) + if not os.path.exists(src): + logging.error('INSTALL manifest points to non-existant file (Line %d)', lc) + return None + config['install'].append({'src': src, 'dst': dst}) + elif state == 2: + if line != '': + config['config'].append(line) + elif state == 3: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + if key == '' or value == '': + logging.error('Options section cannot have an empty key or value (Line %d)', lc) + return None + if key in config['options']: + logging.warning( + 'Key "%s" will be overridden since it is defined multiple times (Line %d)', lc) + if value.lower() in ['true', 'yes']: + value = True + elif value.lower() in ['false', 'no']: + value = False + config['options'][key] = value + except: + logging.exception('Failed to read INSTALL manifest') + return None - # Support old INSTALL format - if 'config' not in config: - logging.info('All drivers have typically ONE config value, this must be an old INSTALL file, try to compensate') - config['config'] = [] - for k in config['options']: - config['config'].append('%s=%s' % (k, config['options'][k])) - config.pop('options', None) + # Support old INSTALL format + if 'config' not in config: + logging.info('All drivers have typically ONE config value, this must be an old INSTALL file, try to compensate') + config['config'] = [] + for k in config['options']: + config['config'].append('%s=%s' % (k, config['options'][k])) + config.pop('options', None) - return config + return config - def install(self, file): - ''' - Takes a zip file, extracts it and stores the necessary parts in a new - folder under EXTERNAL. Does NOT make it active. - ''' - folder = tempfile.mkdtemp() - extra, _ = os.path.basename(file).rsplit('.', 1) # This is to make sure we have a foldername - try: - result = subprocess.check_call(['/usr/bin/unzip', file, '-d', os.path.join(folder, extra)], stdout=self.void, stderr=self.void) - except: - result = 255 + def install(self, file): + ''' + Takes a zip file, extracts it and stores the necessary parts in a new + folder under EXTERNAL. Does NOT make it active. + ''' + folder = tempfile.mkdtemp() + extra, _ = os.path.basename(file).rsplit('.', 1) # This is to make sure we have a foldername + try: + result = subprocess.check_call( + ['/usr/bin/unzip', file, '-d', os.path.join(folder, extra)], stdout=self.void, stderr=self.void) + except: + result = 255 - if result != 0: - logging.error('Failed to extract files from zipfile') - self._deletefolder(folder) - return False + if result != 0: + logging.error('Failed to extract files from zipfile') + self._deletefolder(folder) + return False - # Locate the meat of the file, ie, the INSTALL file - installer = self._find('INSTALL', folder) - if installer is None: - logging.error('No INSTALL manifest, abort driver installation') - self._deletefolder(folder) - return False + # Locate the meat of the file, ie, the INSTALL file + installer = self._find('INSTALL', folder) + if installer is None: + logging.error('No INSTALL manifest, abort driver installation') + self._deletefolder(folder) + return False - config = self._parse(installer) - if config is None: - logging.error('INSTALL manifest corrupt, abort driver installation') - self._deletefolder(folder) - return False + config = self._parse(installer) + if config is None: + logging.error('INSTALL manifest corrupt, abort driver installation') + self._deletefolder(folder) + return False - # First, make sure we erase existing driver - dstfolder = os.path.join(path.DRV_EXTERNAL, config['driver']) - if os.path.exists(dstfolder): - logging.info('"%s" already exists, delete before installing', dstfolder) - self._deletefolder(dstfolder) - os.mkdir(dstfolder) + # First, make sure we erase existing driver + dstfolder = os.path.join(path.DRV_EXTERNAL, config['driver']) + if os.path.exists(dstfolder): + logging.info('"%s" already exists, delete before installing', dstfolder) + self._deletefolder(dstfolder) + os.mkdir(dstfolder) - # Copy all files as needed - files = [] - for entry in config['install']: - src = entry['src'] - dst = os.path.basename(entry['src']).replace('/', '_') - files.append({'src':dst, 'dst':entry['dst']}) - try: - shutil.copyfile(os.path.join(folder, extra, src), os.path.join(dstfolder, dst)) - except: - logging.exception('Failed to copy "%s" to "%s"', os.path.join(folder, extra, src), os.path.join(dstfolder, dst)) - # Shitty, but we cannot leave this directory with partial files - self._deletefolder(dstfolder) - self._deletefolder(folder) - return False - config['install'] = files + # Copy all files as needed + files = [] + for entry in config['install']: + src = entry['src'] + dst = os.path.basename(entry['src']).replace('/', '_') + files.append({'src': dst, 'dst': entry['dst']}) + try: + shutil.copyfile(os.path.join(folder, extra, src), os.path.join(dstfolder, dst)) + except: + logging.exception('Failed to copy "%s" to "%s"', os.path.join( + folder, extra, src), os.path.join(dstfolder, dst)) + # Shitty, but we cannot leave this directory with partial files + self._deletefolder(dstfolder) + self._deletefolder(folder) + return False + config['install'] = files - # Just save our config, saving us time next time - with open(os.path.join(dstfolder, 'manifest.json'), 'w') as f: - json.dump(config, f) + # Just save our config, saving us time next time + with open(os.path.join(dstfolder, 'manifest.json'), 'w') as f: + json.dump(config, f) - self._deletefolder(folder) - return config + self._deletefolder(folder) + return config - def isint(self, value): - try: - int(value) - return True - except: - return False + def isint(self, value): + try: + int(value) + return True + except: + return False - def activate(self, driver=None): - ''' - Activates a driver, meaning it gets copied into the necessary places and - the config.txt is updated. Setting driver to None removes the active driver - ''' - driverlist = self.list() - if driver is not None: - # Check that this driver exists - if driver not in driverlist: - logging.error('Tried to active non-existant driver "%s"', driver) - return None + def activate(self, driver=None): + ''' + Activates a driver, meaning it gets copied into the necessary places and + the config.txt is updated. Setting driver to None removes the active driver + ''' + driverlist = self.list() + if driver is not None: + # Check that this driver exists + if driver not in driverlist: + logging.error('Tried to active non-existant driver "%s"', driver) + return None - config = {'name':'', 'install':[], 'config' : [], 'options':{}} - root = '' - if driver: - try: - with open(os.path.join(driverlist[driver], 'manifest.json'), 'rb') as f: - config = json.load(f) - root = driverlist[driver] - except: - logging.exception('Failed to load manifest for %s', driver) - return None - # Reformat old - if 'version' not in config: - logging.debug('Old driver, rejigg the data') - if 'options' in config: - config['config'] = config['options'] - config.pop('options', None) - if 'special' in config: - config['options'] = config['special'] - config.pop('special', None) + config = {'name': '', 'install': [], 'config': [], 'options': {}} + root = '' + if driver: + try: + with open(os.path.join(driverlist[driver], 'manifest.json'), 'rb') as f: + config = json.load(f) + root = driverlist[driver] + except: + logging.exception('Failed to load manifest for %s', driver) + return None + # Reformat old + if 'version' not in config: + logging.debug('Old driver, rejigg the data') + if 'options' in config: + config['config'] = config['options'] + config.pop('options', None) + if 'special' in config: + config['options'] = config['special'] + config.pop('special', None) - # Copy the files into desired locations - for copy in config['install']: - try: - shutil.copyfile(os.path.join(root, copy['src']), copy['dst']) - except: - logging.exception('Failed to copy "%s" to "%s"', copy['src'], copy['dst']) - return None + # Copy the files into desired locations + for copy in config['install']: + try: + shutil.copyfile(os.path.join(root, copy['src']), copy['dst']) + except: + logging.exception('Failed to copy "%s" to "%s"', copy['src'], copy['dst']) + return None - # Next, load the config.txt and insert/replace our section - lines = [] - try: - with open(path.CONFIG_TXT, 'rb') as f: - for line in f: - line = line.strip() - if line == drivers.MARKER: - break - lines.append(line) - except: - logging.exception('Failed to read /boot/config.txt') - return None + # Next, load the config.txt and insert/replace our section + lines = [] + try: + with open(path.CONFIG_TXT, 'rb') as f: + for line in f: + line = line.strip() + if line == drivers.MARKER: + break + lines.append(line) + except: + logging.exception('Failed to read /boot/config.txt') + return None - # Add our options - if len(config['config']) > 0: - lines.append(drivers.MARKER) - for entry in config['config']: - lines.append(entry) + # Add our options + if len(config['config']) > 0: + lines.append(drivers.MARKER) + for entry in config['config']: + lines.append(entry) - # Save the new file - try: - with open('/boot/config.txt.new', 'wb') as f: - for line in lines: - f.write('%s\n' % line) - except: - logging.exception('Failed to generate new config.txt') - return None + # Save the new file + try: + with open('/boot/config.txt.new', 'wb') as f: + for line in lines: + f.write('%s\n' % line) + except: + logging.exception('Failed to generate new config.txt') + return None - # On success, we rename and delete the old config - try: - os.rename(path.CONFIG_TXT, '/boot/config.txt.old') - os.rename('/boot/config.txt.new', path.CONFIG_TXT) - # Keep the first version of the config.txt just-in-case - if os.path.exists('/boot/config.txt.original'): - os.unlink('/boot/config.txt.old') - else: - os.rename('/boot/config.txt.old', '/boot/config.txt.original') - except: - logging.exception('Failed to activate new config.txt, you may need to restore the config.txt') - return None - if 'special' in config: - return config['special'] - else: - return {} + # On success, we rename and delete the old config + try: + os.rename(path.CONFIG_TXT, '/boot/config.txt.old') + os.rename('/boot/config.txt.new', path.CONFIG_TXT) + # Keep the first version of the config.txt just-in-case + if os.path.exists('/boot/config.txt.original'): + os.unlink('/boot/config.txt.old') + else: + os.rename('/boot/config.txt.old', '/boot/config.txt.original') + except: + logging.exception('Failed to activate new config.txt, you may need to restore the config.txt') + return None + if 'special' in config: + return config['special'] + else: + return {} diff --git a/modules/events.py b/modules/events.py index 9d17eda..cab735f 100755 --- a/modules/events.py +++ b/modules/events.py @@ -14,45 +14,46 @@ # along with photoframe. If not, see . # + class Events: - TYPE_PERSIST = 1 - TYPE_NORMAL = 0 - - LEVEL_INFO = 0 - LEVEL_WARN = 1 - LEVEL_ERR = 2 - LEVEL_DEBUG = 3 - - def __init__(self): - self.idcount = 0 - self.msgs = [] - - def add(self, message, unique=None, link=None, level=LEVEL_INFO, type=TYPE_NORMAL): - record = {'id': self.idcount, 'unique' : unique, 'type' : type, 'level' : level, 'message' : message, 'link' : link} - if unique is not None: - unique = repr(unique) # Make it a string to be safe - for i in range(0, len(self.msgs)): - if self.msgs[i]['unique'] == unique: - self.msgs[i] = record - record = None - break - - if record is not None: - self.msgs.append(record) - self.idcount += 1 - - def remove(self, id): - for i in range(0, len(self.msgs)): - if self.msgs[i]['id'] == id and self.msgs[i]['type'] != Events.TYPE_PERSIST: - self.msgs.pop(i) - break - - def getAll(self): - return self.msgs - - def getSince(self, id): - ret = [] - for msg in self.msgs: - if msg['id'] > id: - ret.append(msg) - return ret + TYPE_PERSIST = 1 + TYPE_NORMAL = 0 + + LEVEL_INFO = 0 + LEVEL_WARN = 1 + LEVEL_ERR = 2 + LEVEL_DEBUG = 3 + + def __init__(self): + self.idcount = 0 + self.msgs = [] + + def add(self, message, unique=None, link=None, level=LEVEL_INFO, type=TYPE_NORMAL): + record = {'id': self.idcount, 'unique': unique, 'type': type, 'level': level, 'message': message, 'link': link} + if unique is not None: + unique = repr(unique) # Make it a string to be safe + for i in range(0, len(self.msgs)): + if self.msgs[i]['unique'] == unique: + self.msgs[i] = record + record = None + break + + if record is not None: + self.msgs.append(record) + self.idcount += 1 + + def remove(self, id): + for i in range(0, len(self.msgs)): + if self.msgs[i]['id'] == id and self.msgs[i]['type'] != Events.TYPE_PERSIST: + self.msgs.pop(i) + break + + def getAll(self): + return self.msgs + + def getSince(self, id): + ret = [] + for msg in self.msgs: + if msg['id'] > id: + ret.append(msg) + return ret diff --git a/modules/helper.py b/modules/helper.py index 4e37029..0e231da 100755 --- a/modules/helper.py +++ b/modules/helper.py @@ -24,371 +24,377 @@ # A regular expression to determine whether a url is valid or not (e.g. "www.example.de/someImg.jpg" is missing "http://") VALID_URL_REGEX = re.compile( - r'^(?:http|ftp)s?://' # http:// or https:// - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... - r'localhost|' # localhost... - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip - r'(?::\d+)?' # optional port - r'(?:/?|[/?]\S+)$', re.IGNORECASE) + r'^(?:http|ftp)s?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + class helper: - TOOL_ROTATE = '/usr/bin/jpegtran' - - MIMETYPES = { - 'image/jpeg' : 'jpg', - 'image/png' : 'png', - 'image/gif' : 'gif', - 'image/bmp' : 'bmp' - # HEIF to be added once I get ImageMagick running with support - } - - - @staticmethod - def isValidUrl(url): - # Catches most invalid URLs - if re.match(VALID_URL_REGEX, url) is None: - return False - return True - - @staticmethod - def getWeightedRandomIndex(weights): - totalWeights = sum(weights) - normWeights = [float(w)/totalWeights for w in weights] - x = random.SystemRandom().random() - for i in range(len(normWeights)): - x -= normWeights[i] - if x <= 0.: - return i - - @staticmethod - def getResolution(): - res = None - output = subprocess.check_output(['/bin/fbset'], stderr=subprocess.DEVNULL) - for line in output.split('\n'): - line = line.strip() - if line.startswith('mode "'): - res = line[6:-1] - break - return res - - @staticmethod - def getDeviceIp(): - try: - import netifaces - if 'default' in netifaces.gateways() and netifaces.AF_INET in netifaces.gateways()['default']: - dev = netifaces.gateways()['default'][netifaces.AF_INET][1] - if netifaces.ifaddresses(dev) and netifaces.AF_INET in netifaces.ifaddresses(dev): - net = netifaces.ifaddresses(dev)[netifaces.AF_INET] - if len(net) > 0 and 'addr' in net[0]: - return net[0]['addr'] - except ImportError: - logging.error('User has not installed python-netifaces, using checkNetwork() instead (depends on internet)') - return helper._checkNetwork() - except: - logging.exception('netifaces call failed, using checkNetwork() instead (depends on internet)') - return helper._checkNetwork() - - @staticmethod - def _checkNetwork(): - ip = None - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("photoframe.sensenet.nu", 80)) - ip = s.getsockname()[0] - - s.close() - except: - logging.exception('Failed to get IP via old method') - return ip - - @staticmethod - def getExtension(mime): - mime = mime.lower() - if mime in helper.MIMETYPES: - return helper.MIMETYPES[mime] - return None - - @staticmethod - def getSupportedTypes(): - ret = [] - for i in helper.MIMETYPES: - ret.append(i) - return ret - - @staticmethod - def getMimetype(filename): - if not os.path.isfile(filename): - return None - - mimetype = '' - cmd = ["/usr/bin/file", "--mime", filename] - with open(os.devnull, 'wb') as void: - try: - output = subprocess.check_output(cmd, stderr=void).strip("\n") - m = re.match('[^\:]+\: *([^;]+)', output) - if m: - mimetype = m.group(1) - except subprocess.CalledProcessError: - logging.debug("unable to determine mimetype of file: %s" % filename) - return None - return mimetype - - @staticmethod - def copyFile(orgFilename, newFilename): - try: - shutil.copyfile(orgFilename, newFilename) - except: - logging.exception('Unable copy file from "%s" to "%s"'%(orgFilename, newFilename)) - return False - return True - - @staticmethod - def scaleImage(orgFilename, newFilename, newSize): - cmd = [ - 'convert', - orgFilename + '[0]', - '-scale', - '%sx%s' % (newSize["width"], newSize["height"]), - '+repage', - newFilename + TOOL_ROTATE = '/usr/bin/jpegtran' + + MIMETYPES = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/bmp': 'bmp' + # HEIF to be added once I get ImageMagick running with support + } + + @staticmethod + def isValidUrl(url): + # Catches most invalid URLs + if re.match(VALID_URL_REGEX, url) is None: + return False + return True + + @staticmethod + def getWeightedRandomIndex(weights): + totalWeights = sum(weights) + normWeights = [float(w)/totalWeights for w in weights] + x = random.SystemRandom().random() + for i in range(len(normWeights)): + x -= normWeights[i] + if x <= 0.: + return i + + @staticmethod + def getResolution(): + res = None + output = subprocess.check_output(['/bin/fbset'], stderr=subprocess.DEVNULL) + for line in output.split('\n'): + line = line.strip() + if line.startswith('mode "'): + res = line[6:-1] + break + return res + + @staticmethod + def getDeviceIp(): + try: + import netifaces + if 'default' in netifaces.gateways() and netifaces.AF_INET in netifaces.gateways()['default']: + dev = netifaces.gateways()['default'][netifaces.AF_INET][1] + if netifaces.ifaddresses(dev) and netifaces.AF_INET in netifaces.ifaddresses(dev): + net = netifaces.ifaddresses(dev)[netifaces.AF_INET] + if len(net) > 0 and 'addr' in net[0]: + return net[0]['addr'] + except ImportError: + logging.error('User has not installed python-netifaces, using checkNetwork() instead (depends on internet)') + return helper._checkNetwork() + except: + logging.exception('netifaces call failed, using checkNetwork() instead (depends on internet)') + return helper._checkNetwork() + + @staticmethod + def _checkNetwork(): + ip = None + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("photoframe.sensenet.nu", 80)) + ip = s.getsockname()[0] + + s.close() + except: + logging.exception('Failed to get IP via old method') + return ip + + @staticmethod + def getExtension(mime): + mime = mime.lower() + if mime in helper.MIMETYPES: + return helper.MIMETYPES[mime] + return None + + @staticmethod + def getSupportedTypes(): + ret = [] + for i in helper.MIMETYPES: + ret.append(i) + return ret + + @staticmethod + def getMimetype(filename): + if not os.path.isfile(filename): + return None + + mimetype = '' + cmd = ["/usr/bin/file", "--mime", filename] + with open(os.devnull, 'wb') as void: + try: + output = subprocess.check_output(cmd, stderr=void).strip("\n") + m = re.match('[^\:]+\: *([^;]+)', output) + if m: + mimetype = m.group(1) + except subprocess.CalledProcessError: + logging.debug("unable to determine mimetype of file: %s" % filename) + return None + return mimetype + + @staticmethod + def copyFile(orgFilename, newFilename): + try: + shutil.copyfile(orgFilename, newFilename) + except: + logging.exception('Unable copy file from "%s" to "%s"' % (orgFilename, newFilename)) + return False + return True + + @staticmethod + def scaleImage(orgFilename, newFilename, newSize): + cmd = [ + 'convert', + orgFilename + '[0]', + '-scale', + '%sx%s' % (newSize["width"], newSize["height"]), + '+repage', + newFilename ] - try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - logging.exception('Unable to reframe the image') - logging.error('Output: %s' % repr(e.output)) - return False - return True - - @staticmethod - def getImageSize(filename): - if not os.path.isfile(filename): - logging.warning('File %s does not exist, so cannot get dimensions', filename) - return None - - with open(os.devnull, 'wb') as void: - try: - output = subprocess.check_output(['/usr/bin/identify', filename], stderr=void) - except: - logging.exception('Failed to run identify to get image dimensions on %s', filename) - return None - - m = re.search('([1-9][0-9]*)x([1-9][0-9]*)', output) - if m is None or m.groups() is None or len(m.groups()) != 2: - logging.error('Unable to resolve regular expression for image size') - return None - - imageSize = {} - imageSize["width"] = int(m.group(1)) - imageSize["height"] = int(m.group(2)) - - return imageSize - - @staticmethod - def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoChoose=False): - imageSize = helper.getImageSize(filename) - if imageSize is None: - logging.warning('Cannot frame %s since we cannot determine image dimensions', filename) - return filename - - width = imageSize["width"] - height = imageSize["height"] - - p, f = os.path.split(filename) - filenameProcessed = os.path.join(p, "framed_" + f) - - width_border = 15 - width_spacing = 3 - border = None - spacing = None - - # Calculate actual size of image based on display - oar = (float)(width) / (float)(height) - dar = (float)(displayWidth) / (float)(displayHeight) - - if not zoomOnly: - if oar >= dar: - adjWidth = displayWidth - adjHeight = int(float(displayWidth) / oar) - else: - adjWidth = int(float(displayHeight) * oar) - adjHeight = displayHeight - - logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d', width, height, displayWidth, displayHeight, adjWidth, adjHeight) - - if width < 100 or height < 100: - logging.error('Image size is REALLY small, please check "%s" ... something isn\'t right', filename) - #a=1/0 - - if adjHeight < displayHeight: - border = '0x%d' % width_border - spacing = '0x%d' % width_spacing - padding = ((displayHeight - adjHeight) / 2 - width_border) - resizeString = '%sx%s^' - logging.debug('Landscape image, reframing (padding required %dpx)' % padding) - elif adjWidth < displayWidth: - border = '%dx0' % width_border - spacing = '%dx0' % width_spacing - padding = ((displayWidth - adjWidth) / 2 - width_border) - resizeString = '^%sx%s' - logging.debug('Portrait image, reframing (padding required %dpx)' % padding) - else: - resizeString = '%sx%s' - logging.debug('Image is fullscreen, no reframing needed') - return filename - - if padding < 20 and not autoChoose: - logging.debug('That\'s less than 20px so skip reframing (%dx%d => %dx%d)', width, height, adjWidth, adjHeight) - return filename - - if padding < 60 and autoChoose: - zoomOnly = True - - if zoomOnly: - if oar <= dar: - adjWidth = displayWidth - adjHeight = int(float(displayWidth) / oar) - logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d --> cropped to %dx%d', width, height, displayWidth, displayHeight, adjWidth, adjHeight, displayWidth, displayHeight) - else: - adjWidth = int(float(displayHeight) * oar) - adjHeight = displayHeight - logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d --> cropped to %dx%d', width, height, displayWidth, displayHeight, adjWidth, adjHeight, displayWidth, displayHeight) - - cmd = None - try: - # Time to process - if zoomOnly: - cmd = [ - 'convert', - filename + '[0]', - '-resize', - '%sx%s' % (adjWidth, adjHeight), - '-gravity', - 'center', - '-crop', - '%sx%s+0+0' % (displayWidth, displayHeight), - '+repage', - filenameProcessed - ] - else: - cmd = [ - 'convert', - filename + '[0]', - '-resize', - resizeString % (displayWidth, displayHeight), - '-gravity', - 'center', - '-crop', - '%sx%s+0+0' % (displayWidth, displayHeight), - '+repage', - '-blur', - '0x12', - '-brightness-contrast', - '-20x0', - '(', - filename + '[0]', - '-bordercolor', - 'black', - '-border', - border, - '-bordercolor', - 'black', - '-border', - spacing, - '-resize', - '%sx%s' % (displayWidth, displayHeight), - '-background', - 'transparent', - '-gravity', - 'center', - '-extent', - '%sx%s' % (displayWidth, displayHeight), - ')', - '-composite', - filenameProcessed - ] - except: - logging.exception('Error building command line') - logging.debug('Filename: ' + repr(filename)) - logging.debug('filenameProcessed: ' + repr(filenameProcessed)) - logging.debug('border: ' + repr(border)) - logging.debug('spacing: ' + repr(spacing)) - return filename - - try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - logging.exception('Unable to reframe the image') - logging.error('Output: %s' % repr(e.output)) - return filename - os.unlink(filename) - return filenameProcessed - - @staticmethod - def timezoneList(): - zones = subprocess.check_output(['/usr/bin/timedatectl', 'list-timezones']).split('\n') - return [x for x in zones if x] - - @staticmethod - def timezoneCurrent(): - with open('/etc/timezone', 'r') as f: - result = f.readlines() - return result[0].strip() - - @staticmethod - def timezoneSet(zone): - result = 1 - try: - with open(os.devnull, 'wb') as void: - result = subprocess.check_call(['/usr/bin/timedatectl', 'set-timezone', zone], stderr=void) - except: - logging.exception('Unable to change timezone') - pass - return result == 0 - - @staticmethod - def hasNetwork(): - return helper._checkNetwork() is not None - - @staticmethod - def waitForNetwork(funcNoNetwork, funcExit): - shownError = False - while True and not funcExit(): - if not helper.hasNetwork(): - funcNoNetwork() - if not shownError: - logging.error('You must have functional internet connection to use this app') - shownError = True - time.sleep(10) - else: - logging.info('Network connection reestablished') - break - - @staticmethod - def autoRotate(ifile): - if not os.path.exists('/usr/bin/jpegexiforient'): - logging.warning('jpegexiforient is missing, no auto rotate available. Did you forget to run "apt install libjpeg-turbo-progs" ?') - return ifile - - p, f = os.path.split(ifile) - ofile = os.path.join(p, "rotated_" + f) - - # First, use jpegexiftran to determine orientation - parameters = ['', '-flip horizontal', '-rotate 180', '-flip vertical', '-transpose', '-rotate 90', '-transverse', '-rotate 270'] - with open(os.devnull, 'wb') as void: - result = subprocess.check_output(['/usr/bin/jpegexiforient', ifile]) #, stderr=void) - if result: - orient = int(result)-1 - if orient < 0 or orient >= len(parameters): - logging.info('Orientation was %d, not transforming it', orient) - return ifile - cmd = [helper.TOOL_ROTATE] - cmd.extend(parameters[orient].split()) - cmd.extend(['-outfile', ofile, ifile]) - with open(os.devnull, 'wb') as void: - result = subprocess.check_call(cmd, stderr=void) - if result == 0: - os.unlink(ifile) - return ofile - return ifile + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + logging.exception('Unable to reframe the image') + logging.error('Output: %s' % repr(e.output)) + return False + return True + + @staticmethod + def getImageSize(filename): + if not os.path.isfile(filename): + logging.warning('File %s does not exist, so cannot get dimensions', filename) + return None + + with open(os.devnull, 'wb') as void: + try: + output = subprocess.check_output(['/usr/bin/identify', filename], stderr=void) + except: + logging.exception('Failed to run identify to get image dimensions on %s', filename) + return None + + m = re.search('([1-9][0-9]*)x([1-9][0-9]*)', output) + if m is None or m.groups() is None or len(m.groups()) != 2: + logging.error('Unable to resolve regular expression for image size') + return None + + imageSize = {} + imageSize["width"] = int(m.group(1)) + imageSize["height"] = int(m.group(2)) + + return imageSize + + @staticmethod + def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoChoose=False): + imageSize = helper.getImageSize(filename) + if imageSize is None: + logging.warning('Cannot frame %s since we cannot determine image dimensions', filename) + return filename + + width = imageSize["width"] + height = imageSize["height"] + + p, f = os.path.split(filename) + filenameProcessed = os.path.join(p, "framed_" + f) + + width_border = 15 + width_spacing = 3 + border = None + spacing = None + + # Calculate actual size of image based on display + oar = (float)(width) / (float)(height) + dar = (float)(displayWidth) / (float)(displayHeight) + + if not zoomOnly: + if oar >= dar: + adjWidth = displayWidth + adjHeight = int(float(displayWidth) / oar) + else: + adjWidth = int(float(displayHeight) * oar) + adjHeight = displayHeight + + logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d', + width, height, displayWidth, displayHeight, adjWidth, adjHeight) + + if width < 100 or height < 100: + logging.error('Image size is REALLY small, please check "%s" ... something isn\'t right', filename) + # a=1/0 + + if adjHeight < displayHeight: + border = '0x%d' % width_border + spacing = '0x%d' % width_spacing + padding = ((displayHeight - adjHeight) / 2 - width_border) + resizeString = '%sx%s^' + logging.debug('Landscape image, reframing (padding required %dpx)' % padding) + elif adjWidth < displayWidth: + border = '%dx0' % width_border + spacing = '%dx0' % width_spacing + padding = ((displayWidth - adjWidth) / 2 - width_border) + resizeString = '^%sx%s' + logging.debug('Portrait image, reframing (padding required %dpx)' % padding) + else: + resizeString = '%sx%s' + logging.debug('Image is fullscreen, no reframing needed') + return filename + + if padding < 20 and not autoChoose: + logging.debug('That\'s less than 20px so skip reframing (%dx%d => %dx%d)', + width, height, adjWidth, adjHeight) + return filename + + if padding < 60 and autoChoose: + zoomOnly = True + + if zoomOnly: + if oar <= dar: + adjWidth = displayWidth + adjHeight = int(float(displayWidth) / oar) + logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d --> cropped to %dx%d', + width, height, displayWidth, displayHeight, adjWidth, adjHeight, displayWidth, displayHeight) + else: + adjWidth = int(float(displayHeight) * oar) + adjHeight = displayHeight + logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d --> cropped to %dx%d', + width, height, displayWidth, displayHeight, adjWidth, adjHeight, displayWidth, displayHeight) + + cmd = None + try: + # Time to process + if zoomOnly: + cmd = [ + 'convert', + filename + '[0]', + '-resize', + '%sx%s' % (adjWidth, adjHeight), + '-gravity', + 'center', + '-crop', + '%sx%s+0+0' % (displayWidth, displayHeight), + '+repage', + filenameProcessed + ] + else: + cmd = [ + 'convert', + filename + '[0]', + '-resize', + resizeString % (displayWidth, displayHeight), + '-gravity', + 'center', + '-crop', + '%sx%s+0+0' % (displayWidth, displayHeight), + '+repage', + '-blur', + '0x12', + '-brightness-contrast', + '-20x0', + '(', + filename + '[0]', + '-bordercolor', + 'black', + '-border', + border, + '-bordercolor', + 'black', + '-border', + spacing, + '-resize', + '%sx%s' % (displayWidth, displayHeight), + '-background', + 'transparent', + '-gravity', + 'center', + '-extent', + '%sx%s' % (displayWidth, displayHeight), + ')', + '-composite', + filenameProcessed + ] + except: + logging.exception('Error building command line') + logging.debug('Filename: ' + repr(filename)) + logging.debug('filenameProcessed: ' + repr(filenameProcessed)) + logging.debug('border: ' + repr(border)) + logging.debug('spacing: ' + repr(spacing)) + return filename + + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + logging.exception('Unable to reframe the image') + logging.error('Output: %s' % repr(e.output)) + return filename + os.unlink(filename) + return filenameProcessed + + @staticmethod + def timezoneList(): + zones = subprocess.check_output(['/usr/bin/timedatectl', 'list-timezones']).split('\n') + return [x for x in zones if x] + + @staticmethod + def timezoneCurrent(): + with open('/etc/timezone', 'r') as f: + result = f.readlines() + return result[0].strip() + + @staticmethod + def timezoneSet(zone): + result = 1 + try: + with open(os.devnull, 'wb') as void: + result = subprocess.check_call(['/usr/bin/timedatectl', 'set-timezone', zone], stderr=void) + except: + logging.exception('Unable to change timezone') + pass + return result == 0 + + @staticmethod + def hasNetwork(): + return helper._checkNetwork() is not None + + @staticmethod + def waitForNetwork(funcNoNetwork, funcExit): + shownError = False + while True and not funcExit(): + if not helper.hasNetwork(): + funcNoNetwork() + if not shownError: + logging.error('You must have functional internet connection to use this app') + shownError = True + time.sleep(10) + else: + logging.info('Network connection reestablished') + break + + @staticmethod + def autoRotate(ifile): + if not os.path.exists('/usr/bin/jpegexiforient'): + logging.warning( + 'jpegexiforient is missing, no auto rotate available. Did you forget to run "apt install libjpeg-turbo-progs" ?') + return ifile + + p, f = os.path.split(ifile) + ofile = os.path.join(p, "rotated_" + f) + + # First, use jpegexiftran to determine orientation + parameters = ['', '-flip horizontal', '-rotate 180', '-flip vertical', + '-transpose', '-rotate 90', '-transverse', '-rotate 270'] + with open(os.devnull, 'wb') as void: + result = subprocess.check_output(['/usr/bin/jpegexiforient', ifile]) # , stderr=void) + if result: + orient = int(result)-1 + if orient < 0 or orient >= len(parameters): + logging.info('Orientation was %d, not transforming it', orient) + return ifile + cmd = [helper.TOOL_ROTATE] + cmd.extend(parameters[orient].split()) + cmd.extend(['-outfile', ofile, ifile]) + with open(os.devnull, 'wb') as void: + result = subprocess.check_call(cmd, stderr=void) + if result == 0: + os.unlink(ifile) + return ofile + return ifile diff --git a/modules/history.py b/modules/history.py index 3a69fd1..e55a10a 100755 --- a/modules/history.py +++ b/modules/history.py @@ -20,56 +20,59 @@ from modules.path import path as syspath + class ImageHistory: - MAX_HISTORY = 20 - def __init__(self, settings): - self._HISTORY = [] - self.settings = settings + MAX_HISTORY = 20 + + def __init__(self, settings): + self._HISTORY = [] + self.settings = settings - # Also, make sure folder exists AND clear any existing content - if not os.path.exists(syspath.HISTORYFOLDER): - os.mkdir(syspath.HISTORYFOLDER) + # Also, make sure folder exists AND clear any existing content + if not os.path.exists(syspath.HISTORYFOLDER): + os.mkdir(syspath.HISTORYFOLDER) - # Clear it - for p, _dirs, files in os.walk(syspath.HISTORYFOLDER): - for filename in [os.path.join(p, f) for f in files]: - try: - os.unlink(filename) - except: - logging.exception('Failed to delete "%s"' % filename) + # Clear it + for p, _dirs, files in os.walk(syspath.HISTORYFOLDER): + for filename in [os.path.join(p, f) for f in files]: + try: + os.unlink(filename) + except: + logging.exception('Failed to delete "%s"' % filename) - def _find(self, file): - return next((entry for entry in self._HISTORY if entry.filename == file), None) + def _find(self, file): + return next((entry for entry in self._HISTORY if entry.filename == file), None) - def add(self, image): - if image is None or image.error is not None: - return - historyFile = os.path.join(syspath.HISTORYFOLDER, image.getCacheId()) - if not self._find(historyFile): - shutil.copy(image.filename, historyFile) - h = image.copy() - h.setFilename(historyFile) - h.allowCache(False) + def add(self, image): + if image is None or image.error is not None: + return + historyFile = os.path.join(syspath.HISTORYFOLDER, image.getCacheId()) + if not self._find(historyFile): + shutil.copy(image.filename, historyFile) + h = image.copy() + h.setFilename(historyFile) + h.allowCache(False) - self._HISTORY.insert(0, h) - self._obeyLimits() + self._HISTORY.insert(0, h) + self._obeyLimits() - def _obeyLimits(self): - # Make sure history isn't too big - while len(self._HISTORY) > ImageHistory.MAX_HISTORY: - entry = self._HISTORY.pop() - if not self._find(entry.filename): - os.unlink(entry.filename) + def _obeyLimits(self): + # Make sure history isn't too big + while len(self._HISTORY) > ImageHistory.MAX_HISTORY: + entry = self._HISTORY.pop() + if not self._find(entry.filename): + os.unlink(entry.filename) - def getAvailable(self): - return len(self._HISTORY) + def getAvailable(self): + return len(self._HISTORY) - def getByIndex(self, index): - if index < 0 or index >= len(self._HISTORY): - logging.warning('History index requested is out of bounds (%d wanted, have 0-%d)', index, len(self._HISTORY)-1) - return None - entry = self._HISTORY[index] - # We need to make a copy which is safe to delete! - f = os.path.join(self.settings.get('tempfolder'), 'history') - shutil.copy(entry.filename, f) - return entry.copy().setFilename(f) + def getByIndex(self, index): + if index < 0 or index >= len(self._HISTORY): + logging.warning('History index requested is out of bounds (%d wanted, have 0-%d)', + index, len(self._HISTORY)-1) + return None + entry = self._HISTORY[index] + # We need to make a copy which is safe to delete! + f = os.path.join(self.settings.get('tempfolder'), 'history') + shutil.copy(entry.filename, f) + return entry.copy().setFilename(f) diff --git a/modules/images.py b/modules/images.py index 5e7b02d..7942913 100755 --- a/modules/images.py +++ b/modules/images.py @@ -15,90 +15,91 @@ # import hashlib + class ImageHolder: - def __init__(self): - # "id" : a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl) - # "mimetype" : the filetype you downloaded, for example "image/jpeg" - # "error" : None or a human readable text string as to why you failed - # "source" : Link to where the item came from or None if not provided - # "url": Link to the actual image file - # "dimensions": a key/value map containing "width" and "height" of the image - # can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams' - # "filename": the original filename of the image or None if unknown (only used for debugging purposes) - self.id = None - self.mimetype = None - self.error = None - self.source = None - self.url = None - self.filename = None - self.dimensions = None - self.cacheAllow = False - self.cacheUsed = False - - self.contentProvider = None - self.contentSource = None - - def setContentProvider(self, provider): - if provider is None: - raise Exception('setContentProvider cannot be None') - self.contentProvider = repr(provider) - return self - - def setContentSource(self, source): - if source is None: - raise Exception('setContentSource cannot be None') - self.contentSource = repr(source) - return self - - def setId(self, id): - self.id = id - return self - - def setMimetype(self, mimetype): - self.mimetype = mimetype - return self - - def setError(self, error): - self.error = error - return self - - def setSource(self, source): - self.source = source - return self - - def setUrl(self, url): - self.url = url - return self - - def setFilename(self, filename): - self.filename = filename - return self - - def setDimensions(self, width, height): - self.dimensions = {'width': int(width), 'height': int(height)} - return self - - def allowCache(self, allow): - self.cacheAllow = allow - return self - - def getCacheId(self): - if self.id is None: - return None - return hashlib.sha1(self.id).hexdigest() - - def copy(self): - copy = ImageHolder() - copy.id = self.id - copy.mimetype = self.mimetype - copy.error = self.error - copy.source = self.source - copy.url = self.url - copy.filename = self.filename - copy.dimensions = self.dimensions - copy.cacheAllow = self.cacheAllow - copy.cacheUsed = self.cacheUsed - - copy.contentProvider = self.contentProvider - copy.contentSource = self.contentSource - return copy + def __init__(self): + # "id" : a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl) + # "mimetype" : the filetype you downloaded, for example "image/jpeg" + # "error" : None or a human readable text string as to why you failed + # "source" : Link to where the item came from or None if not provided + # "url": Link to the actual image file + # "dimensions": a key/value map containing "width" and "height" of the image + # can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams' + # "filename": the original filename of the image or None if unknown (only used for debugging purposes) + self.id = None + self.mimetype = None + self.error = None + self.source = None + self.url = None + self.filename = None + self.dimensions = None + self.cacheAllow = False + self.cacheUsed = False + + self.contentProvider = None + self.contentSource = None + + def setContentProvider(self, provider): + if provider is None: + raise Exception('setContentProvider cannot be None') + self.contentProvider = repr(provider) + return self + + def setContentSource(self, source): + if source is None: + raise Exception('setContentSource cannot be None') + self.contentSource = repr(source) + return self + + def setId(self, id): + self.id = id + return self + + def setMimetype(self, mimetype): + self.mimetype = mimetype + return self + + def setError(self, error): + self.error = error + return self + + def setSource(self, source): + self.source = source + return self + + def setUrl(self, url): + self.url = url + return self + + def setFilename(self, filename): + self.filename = filename + return self + + def setDimensions(self, width, height): + self.dimensions = {'width': int(width), 'height': int(height)} + return self + + def allowCache(self, allow): + self.cacheAllow = allow + return self + + def getCacheId(self): + if self.id is None: + return None + return hashlib.sha1(self.id).hexdigest() + + def copy(self): + copy = ImageHolder() + copy.id = self.id + copy.mimetype = self.mimetype + copy.error = self.error + copy.source = self.source + copy.url = self.url + copy.filename = self.filename + copy.dimensions = self.dimensions + copy.cacheAllow = self.cacheAllow + copy.cacheUsed = self.cacheUsed + + copy.contentProvider = self.contentProvider + copy.contentSource = self.contentSource + return copy diff --git a/modules/memory.py b/modules/memory.py index fed5b5b..ad1ce9f 100755 --- a/modules/memory.py +++ b/modules/memory.py @@ -18,86 +18,87 @@ import logging import hashlib + class MemoryManager: - def __init__(self, memoryLocation): - self._MEMORY = [] - self._MEMORY_KEY = None - self._DIR_MEMORY = memoryLocation - self._MEMORY_COUNT = {} + def __init__(self, memoryLocation): + self._MEMORY = [] + self._MEMORY_KEY = None + self._DIR_MEMORY = memoryLocation + self._MEMORY_COUNT = {} - def _hashString(self, text): - if type(text) is not str: - # make sure it's unicode - a = text.decode('ascii', errors='replace') - else: - a = text - a = a.encode('utf-8', errors='replace') - return hashlib.sha1(a).hexdigest() + def _hashString(self, text): + if type(text) is not str: + # make sure it's unicode + a = text.decode('ascii', errors='replace') + else: + a = text + a = a.encode('utf-8', errors='replace') + return hashlib.sha1(a).hexdigest() - def _fetch(self, key): - if key is None: - raise Exception('No key provided to _fetch') - h = self._hashString(key) - if self._MEMORY_KEY == h: - return - # Save work and swap - if self._MEMORY is not None and len(self._MEMORY) > 0: - with open(os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY), 'w') as f: - json.dump(self._MEMORY, f) - if os.path.exists(os.path.join(self._DIR_MEMORY, '%s.json' % h)): - try: - with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f: - self._MEMORY = json.load(f) - except: - logging.exception('File %s is corrupt' % os.path.join(self._DIR_MEMORY, '%s.json' % h)) - self._MEMORY = [] - else: - logging.debug('_fetch returned no memory') - self._MEMORY = [] - self._MEMORY_COUNT[h] = len(self._MEMORY) - self._MEMORY_KEY = h + def _fetch(self, key): + if key is None: + raise Exception('No key provided to _fetch') + h = self._hashString(key) + if self._MEMORY_KEY == h: + return + # Save work and swap + if self._MEMORY is not None and len(self._MEMORY) > 0: + with open(os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY), 'w') as f: + json.dump(self._MEMORY, f) + if os.path.exists(os.path.join(self._DIR_MEMORY, '%s.json' % h)): + try: + with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f: + self._MEMORY = json.load(f) + except: + logging.exception('File %s is corrupt' % os.path.join(self._DIR_MEMORY, '%s.json' % h)) + self._MEMORY = [] + else: + logging.debug('_fetch returned no memory') + self._MEMORY = [] + self._MEMORY_COUNT[h] = len(self._MEMORY) + self._MEMORY_KEY = h - def remember(self, itemId, keywords, alwaysRemember=True): - # The MEMORY makes sure that this image won't be shown again until memoryForget is called - self._fetch(keywords) - h = self._hashString(itemId) - if h not in self._MEMORY: - self._MEMORY.append(h) - k = self._hashString(keywords) - if k in self._MEMORY_COUNT: - self._MEMORY_COUNT[k] += 1 - else: - self._MEMORY_COUNT[k] = 1 + def remember(self, itemId, keywords, alwaysRemember=True): + # The MEMORY makes sure that this image won't be shown again until memoryForget is called + self._fetch(keywords) + h = self._hashString(itemId) + if h not in self._MEMORY: + self._MEMORY.append(h) + k = self._hashString(keywords) + if k in self._MEMORY_COUNT: + self._MEMORY_COUNT[k] += 1 + else: + self._MEMORY_COUNT[k] = 1 - # save memory - if (len(self._MEMORY) % 20) == 0: - logging.info('Interim saving of memory every 20 entries') - with open(os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY), 'w') as f: - json.dump(self._MEMORY, f) + # save memory + if (len(self._MEMORY) % 20) == 0: + logging.info('Interim saving of memory every 20 entries') + with open(os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY), 'w') as f: + json.dump(self._MEMORY, f) - def getList(self, keywords): - self._fetch(keywords) - return self._MEMORY + def getList(self, keywords): + self._fetch(keywords) + return self._MEMORY - def count(self, keywords): - if self._MEMORY_KEY is None: - self._fetch(keywords) - h = self._hashString(keywords) - if h in self._MEMORY_COUNT: - return self._MEMORY_COUNT[h] - return 0 + def count(self, keywords): + if self._MEMORY_KEY is None: + self._fetch(keywords) + h = self._hashString(keywords) + if h in self._MEMORY_COUNT: + return self._MEMORY_COUNT[h] + return 0 - def seen(self, itemId, keywords): - self._fetch(keywords) - h = self._hashString(itemId) - return h in self._MEMORY + def seen(self, itemId, keywords): + self._fetch(keywords) + h = self._hashString(itemId) + return h in self._MEMORY - def forget(self, keywords): - self._fetch(keywords) - n = os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY) - if os.path.exists(n): - logging.debug('Removed memory file %s' % n) - os.unlink(n) - logging.debug('Has %d memories before wipe' % len(self._MEMORY)) - self._MEMORY = [] - self._MEMORY_COUNT.pop(self._hashString(keywords), None) + def forget(self, keywords): + self._fetch(keywords) + n = os.path.join(self._DIR_MEMORY, '%s.json' % self._MEMORY_KEY) + if os.path.exists(n): + logging.debug('Removed memory file %s' % n) + os.unlink(n) + logging.debug('Has %d memories before wipe' % len(self._MEMORY)) + self._MEMORY = [] + self._MEMORY_COUNT.pop(self._hashString(keywords), None) diff --git a/modules/network.py b/modules/network.py index a89adb8..e4e69bf 100755 --- a/modules/network.py +++ b/modules/network.py @@ -13,6 +13,8 @@ # You should have received a copy of the GNU General Public License # along with photoframe. If not, see . # + + class RequestResult: SUCCESS = 0 UNKNOWN = -1 @@ -66,11 +68,14 @@ def isSuccess(self): def isNoNetwork(self): return self.result == RequestResult.NO_NETWORK + class RequestNoNetwork(Exception): pass + class RequestInvalidToken(Exception): pass + class RequestExpiredToken(Exception): - pass \ No newline at end of file + pass diff --git a/modules/oauth.py b/modules/oauth.py index 9f64e8b..2513172 100755 --- a/modules/oauth.py +++ b/modules/oauth.py @@ -25,127 +25,128 @@ from modules.network import RequestInvalidToken from modules.network import RequestExpiredToken -class OAuth: - def __init__(self, setToken, getToken, scope, extras=''): - self.ip = helper.getDeviceIp() - self.scope = scope - self.oauth = None - self.cbGetToken = getToken - self.cbSetToken = setToken - self.ridURI = 'https://photoframe.sensenet.nu' - self.state = None - self.extras = extras - - def setOAuth(self, oauth): - self.oauth = oauth - - def hasOAuth(self): - return self.oauth != None - - def getSession(self, refresh=False): - if not refresh: - auth = OAuth2Session(self.oauth['client_id'], token=self.cbGetToken()) - else: - auth = OAuth2Session(self.oauth['client_id'], - token=self.cbGetToken(), - auto_refresh_kwargs={'client_id' : self.oauth['client_id'], 'client_secret' : self.oauth['client_secret']}, - auto_refresh_url=self.oauth['token_uri'], - token_updater=self.cbSetToken) - return auth - - def request(self, uri, destination=None, params=None, data=None, usePost=False): - ret = RequestResult() - result = None - stream = destination != None - tries = 0 - - while tries < 5: - try: - try: - auth = self.getSession() - if auth is None: - logging.error('Unable to get OAuth session, probably expired') - raise RequestExpiredToken - if usePost: - result = auth.post(uri, stream=stream, params=params, json=data, timeout=180) - else: - result = auth.get(uri, stream=stream, params=params, timeout=180) - if result is not None: - break - except TokenExpiredError: - auth = self.getSession(True) - if auth is None: - logging.error('Unable to get OAuth session, probably expired') - raise RequestExpiredToken - - if usePost: - result = auth.post(uri, stream=stream, params=params, json=data, timeout=180) - else: - result = auth.get(uri, stream=stream, params=params, timeout=180) - if result is not None: - break - except InvalidGrantError: - logging.error('Token is no longer valid, need to re-authenticate') - raise RequestInvalidToken - except: - logging.exception('Issues downloading') - time.sleep(tries / 10) # Back off 10, 20, ... depending on tries - tries += 1 - logging.warning('Retrying again, attempt #%d', tries) - - if tries == 5: - logging.error('Failed to download due to network issues') - raise RequestNoNetwork - - if destination is not None: - try: - with open(destination, 'wb') as handle: - for chunk in result.iter_content(chunk_size=512): - if chunk: # filter out keep-alive new chunks - handle.write(chunk) - ret.setResult(RequestResult.SUCCESS).setHTTPCode(result.status_code) - ret.setHeaders(result.headers) - except: - logging.exception('Failed to download %s' % uri) - ret.setResult(RequestResult.FAILED_SAVING) - else: - ret.setResult(RequestResult.SUCCESS).setHTTPCode(result.status_code) - ret.setHeaders(result.headers) - ret.setContent(result.content) - return ret - - def getRedirectId(self): - r = requests.get('%s/?register' % self.ridURI) - return r.content - - def initiate(self): - self.rid = self.getRedirectId() - - auth = OAuth2Session(self.oauth['client_id'], - scope=self.scope, # ['https://www.googleapis.com/auth/photos'], - redirect_uri=self.ridURI, - state='%s-%s-%s' % (self.rid, self.ip, self.extras)) - authorization_url, state = auth.authorization_url(self.oauth['auth_uri'], - access_type="offline", - prompt="consent") - - self.state = state - return authorization_url - - def complete(self, url): - try: - auth = OAuth2Session(self.oauth['client_id'], - scope=self.scope, # ['https://www.googleapis.com/auth/photos'], - redirect_uri=self.ridURI, - state='%s-%s-%s' % (self.rid, self.ip, self.extras)) - - token = auth.fetch_token(self.oauth['token_uri'], - client_secret=self.oauth['client_secret'], - authorization_response=url) - - self.cbSetToken(token) - return True - except: - logging.exception('Failed to complete OAuth') - return False +class OAuth: + def __init__(self, setToken, getToken, scope, extras=''): + self.ip = helper.getDeviceIp() + self.scope = scope + self.oauth = None + self.cbGetToken = getToken + self.cbSetToken = setToken + self.ridURI = 'https://photoframe.sensenet.nu' + self.state = None + self.extras = extras + + def setOAuth(self, oauth): + self.oauth = oauth + + def hasOAuth(self): + return self.oauth != None + + def getSession(self, refresh=False): + if not refresh: + auth = OAuth2Session(self.oauth['client_id'], token=self.cbGetToken()) + else: + auth = OAuth2Session(self.oauth['client_id'], + token=self.cbGetToken(), + auto_refresh_kwargs={ + 'client_id': self.oauth['client_id'], 'client_secret': self.oauth['client_secret']}, + auto_refresh_url=self.oauth['token_uri'], + token_updater=self.cbSetToken) + return auth + + def request(self, uri, destination=None, params=None, data=None, usePost=False): + ret = RequestResult() + result = None + stream = destination != None + tries = 0 + + while tries < 5: + try: + try: + auth = self.getSession() + if auth is None: + logging.error('Unable to get OAuth session, probably expired') + raise RequestExpiredToken + if usePost: + result = auth.post(uri, stream=stream, params=params, json=data, timeout=180) + else: + result = auth.get(uri, stream=stream, params=params, timeout=180) + if result is not None: + break + except TokenExpiredError: + auth = self.getSession(True) + if auth is None: + logging.error('Unable to get OAuth session, probably expired') + raise RequestExpiredToken + + if usePost: + result = auth.post(uri, stream=stream, params=params, json=data, timeout=180) + else: + result = auth.get(uri, stream=stream, params=params, timeout=180) + if result is not None: + break + except InvalidGrantError: + logging.error('Token is no longer valid, need to re-authenticate') + raise RequestInvalidToken + except: + logging.exception('Issues downloading') + time.sleep(tries / 10) # Back off 10, 20, ... depending on tries + tries += 1 + logging.warning('Retrying again, attempt #%d', tries) + + if tries == 5: + logging.error('Failed to download due to network issues') + raise RequestNoNetwork + + if destination is not None: + try: + with open(destination, 'wb') as handle: + for chunk in result.iter_content(chunk_size=512): + if chunk: # filter out keep-alive new chunks + handle.write(chunk) + ret.setResult(RequestResult.SUCCESS).setHTTPCode(result.status_code) + ret.setHeaders(result.headers) + except: + logging.exception('Failed to download %s' % uri) + ret.setResult(RequestResult.FAILED_SAVING) + else: + ret.setResult(RequestResult.SUCCESS).setHTTPCode(result.status_code) + ret.setHeaders(result.headers) + ret.setContent(result.content) + return ret + + def getRedirectId(self): + r = requests.get('%s/?register' % self.ridURI) + return r.content + + def initiate(self): + self.rid = self.getRedirectId() + + auth = OAuth2Session(self.oauth['client_id'], + scope=self.scope, # ['https://www.googleapis.com/auth/photos'], + redirect_uri=self.ridURI, + state='%s-%s-%s' % (self.rid, self.ip, self.extras)) + authorization_url, state = auth.authorization_url(self.oauth['auth_uri'], + access_type="offline", + prompt="consent") + + self.state = state + return authorization_url + + def complete(self, url): + try: + auth = OAuth2Session(self.oauth['client_id'], + scope=self.scope, # ['https://www.googleapis.com/auth/photos'], + redirect_uri=self.ridURI, + state='%s-%s-%s' % (self.rid, self.ip, self.extras)) + + token = auth.fetch_token(self.oauth['token_uri'], + client_secret=self.oauth['client_secret'], + authorization_response=url) + + self.cbSetToken(token) + return True + except: + logging.exception('Failed to complete OAuth') + return False diff --git a/modules/path.py b/modules/path.py index 8b5dbe2..c97e5f3 100755 --- a/modules/path.py +++ b/modules/path.py @@ -16,41 +16,42 @@ import os import logging + class path: - CONFIGFOLDER = '/root/photoframe_config' - CONFIGFILE = '/root/photoframe_config/settings.json' - COLORMATCH = '/root/photoframe_config/colortemp.sh' - OPTIONSFILE = '/root/photoframe_config/options' - CACHEFOLDER = '/root/cache/' - HISTORYFOLDER = '/root/history/' + CONFIGFOLDER = '/root/photoframe_config' + CONFIGFILE = '/root/photoframe_config/settings.json' + COLORMATCH = '/root/photoframe_config/colortemp.sh' + OPTIONSFILE = '/root/photoframe_config/options' + CACHEFOLDER = '/root/cache/' + HISTORYFOLDER = '/root/history/' - DRV_BUILTIN = '/root/photoframe/display-drivers' - DRV_EXTERNAL = '/root/photoframe_config/display-drivers/' + DRV_BUILTIN = '/root/photoframe/display-drivers' + DRV_EXTERNAL = '/root/photoframe_config/display-drivers/' - CONFIG_TXT = '/boot/config.txt' + CONFIG_TXT = '/boot/config.txt' - def reassignConfigTxt(self, newconfig): - path.CONFIG_TXT = newconfig + def reassignConfigTxt(self, newconfig): + path.CONFIG_TXT = newconfig - def reassignBase(self, newbase): - path.CONFIGFOLDER = path.CONFIGFOLDER.replace('/root/', newbase) - path.CONFIGFILE = path.CONFIGFILE.replace('/root/', newbase) - path.OPTIONSFILE = path.OPTIONSFILE.replace('/root/', newbase) - path.COLORMATCH = path.COLORMATCH.replace('/root/', newbase) - path.DRV_BUILTIN = path.DRV_BUILTIN.replace('/root/', newbase) - path.DRV_EXTERNAL = path.DRV_EXTERNAL.replace('/root/', newbase) - path.CACHEFOLDER = path.CACHEFOLDER.replace('/root/', newbase) - path.HISTORYFOLDER = path.HISTORYFOLDER.replace('/root/', newbase) + def reassignBase(self, newbase): + path.CONFIGFOLDER = path.CONFIGFOLDER.replace('/root/', newbase) + path.CONFIGFILE = path.CONFIGFILE.replace('/root/', newbase) + path.OPTIONSFILE = path.OPTIONSFILE.replace('/root/', newbase) + path.COLORMATCH = path.COLORMATCH.replace('/root/', newbase) + path.DRV_BUILTIN = path.DRV_BUILTIN.replace('/root/', newbase) + path.DRV_EXTERNAL = path.DRV_EXTERNAL.replace('/root/', newbase) + path.CACHEFOLDER = path.CACHEFOLDER.replace('/root/', newbase) + path.HISTORYFOLDER = path.HISTORYFOLDER.replace('/root/', newbase) - def validate(self): - # Supercritical, since we store all photoframe files in a subdirectory, make sure to create it - if not os.path.exists(path.CONFIGFOLDER): - try: - os.mkdir(path.CONFIGFOLDER) - except: - logging.exception('Unable to create configuration directory, cannot start') - return False - elif not os.path.isdir(path.CONFIGFOLDER): - logging.error('%s isn\'t a folder, cannot start', path.CONFIGFOLDER) - return False - return True + def validate(self): + # Supercritical, since we store all photoframe files in a subdirectory, make sure to create it + if not os.path.exists(path.CONFIGFOLDER): + try: + os.mkdir(path.CONFIGFOLDER) + except: + logging.exception('Unable to create configuration directory, cannot start') + return False + elif not os.path.isdir(path.CONFIGFOLDER): + logging.error('%s isn\'t a folder, cannot start', path.CONFIGFOLDER) + return False + return True diff --git a/modules/remember.py b/modules/remember.py index 40b1bee..64e19bd 100644 --- a/modules/remember.py +++ b/modules/remember.py @@ -18,47 +18,48 @@ import hashlib import logging + class remember: - def __init__(self, filename, count): - self.filename = os.path.splitext(filename)[0] + '_memory.json' - self.count = count - try: - if os.path.exists(self.filename): - with open(self.filename, 'rb') as f: - self.memory = json.load(f) - if 'count' not in self.memory or self.memory['count'] == 0: - self.memory['count'] = count - else: - self.debug() - else: - self.memory = {'seen':[], 'count':count} - except: - logging.exception('Failed to load database') - self.memory = {'seen':[], 'count':count} + def __init__(self, filename, count): + self.filename = os.path.splitext(filename)[0] + '_memory.json' + self.count = count + try: + if os.path.exists(self.filename): + with open(self.filename, 'rb') as f: + self.memory = json.load(f) + if 'count' not in self.memory or self.memory['count'] == 0: + self.memory['count'] = count + else: + self.debug() + else: + self.memory = {'seen': [], 'count': count} + except: + logging.exception('Failed to load database') + self.memory = {'seen': [], 'count': count} - def forget(self): - self.memory = {'seen':[], 'count':0} - if os.path.exists(self.filename): - os.unlink(self.filename) - else: - logging.warning("Asked to delete %s but it doesn't exist", self.filename) + def forget(self): + self.memory = {'seen': [], 'count': 0} + if os.path.exists(self.filename): + os.unlink(self.filename) + else: + logging.warning("Asked to delete %s but it doesn't exist", self.filename) - def _hash(self, text): - return hashlib.sha1(text).hexdigest() + def _hash(self, text): + return hashlib.sha1(text).hexdigest() - def saw(self, url): - index = self._hash(url) - if index not in self.memory['seen']: - self.memory['seen'].append(index) - with open(self.filename, 'wb') as f: - json.dump(self.memory, f) + def saw(self, url): + index = self._hash(url) + if index not in self.memory['seen']: + self.memory['seen'].append(index) + with open(self.filename, 'wb') as f: + json.dump(self.memory, f) - def seenAll(self): - return len(self.memory['seen']) == self.count + def seenAll(self): + return len(self.memory['seen']) == self.count - def debug(self): - logging.info('[%s] Seen %d, expected to see %d', self.filename, len(self.memory['seen']), self.memory['count']) + def debug(self): + logging.info('[%s] Seen %d, expected to see %d', self.filename, len(self.memory['seen']), self.memory['count']) - def seen(self, id): - index = self._hash(id) - return index in self.memory['seen'] + def seen(self, id): + index = self._hash(id) + return index in self.memory['seen'] diff --git a/modules/server.py b/modules/server.py index cdb624b..b0418e1 100755 --- a/modules/server.py +++ b/modules/server.py @@ -28,141 +28,145 @@ from werkzeug.exceptions import HTTPException # used if we don't find authentication json + + class NoAuth: - def __init__(self): - pass + def __init__(self): + pass + + def login_required(self, fn): + def wrap(*args, **kwargs): + return fn(*args, **kwargs) + wrap.__name__ = fn.__name__ + return wrap - def login_required(self, fn): - def wrap(*args, **kwargs): - return fn(*args, **kwargs) - wrap.__name__ = fn.__name__ - return wrap class WebServer(Thread): - def __init__(self, run_async=False, port=7777, listen='0.0.0.0'): - Thread.__init__(self) - self.port = port - self.listen = listen - self.run_async = run_async - - self.app = Flask(__name__, static_url_path='/--do--not--ever--use--this--') - self.app.config['UPLOAD_FOLDER'] = '/tmp/' - self.user = sysconfig.getHTTPAuth() - self.auth = NoAuth() - if self.user is not None: - self.auth = HTTPBasicAuth() - @self.auth.get_password - def check_password(username): - if self.user['user'] == username: - return self.user['password'] - return None - else: - logging.info('No http-auth.json found, disabling http authentication') - - logging.getLogger('werkzeug').setLevel(logging.ERROR) - logging.getLogger('oauthlib').setLevel(logging.ERROR) - logging.getLogger('urllib3').setLevel(logging.ERROR) - - self.app.error_handler_spec = {None: {None : { Exception : self._showException }}} - os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' - self.app.secret_key = os.urandom(24) - self._registerHandlers() - self.authmethod = self.auth.login_required(lambda: None) - self.app.after_request(self._nocache) - self.app.before_request(self._logincheck) - - def _logincheck(self): - if not request.endpoint: - return - - return self.authmethod() - - def start(self): - if self.run_async: - self.start() - else: - self.run() - - def stop(self): - try: - func = request.environ.get('werkzeug.server.shutdown') - if func: - func() - return True - else: - logging.error('Unable to stop webserver, cannot find shutdown() function') - return False - except: - # We're not running with request, so... - raise RuntimeError('Server shutdown') - - def run(self): - try: - self.app.run(debug=False, port=self.port, host=self.listen ) - except RuntimeError as msg: - if str(msg) == "Server shutdown": - pass # or whatever you want to do when the server goes down - else: - raise RuntimeError(msg) - - def _nocache(self, r): - r.headers["Pragma"] = "no-cache" - r.headers["Expires"] = "0" - r.headers['Cache-Control'] = 'public, max-age=0' - return r - - def _showException(self, e): - if isinstance(e, HTTPException): - code = e.code - message = str(e) - else: - code = 500 - #exc_type, exc_value, exc_traceback = sys.exc_info() - lines = traceback.format_exc().splitlines() - #issue = lines[-1] - message = ''' + def __init__(self, run_async=False, port=7777, listen='0.0.0.0'): + Thread.__init__(self) + self.port = port + self.listen = listen + self.run_async = run_async + + self.app = Flask(__name__, static_url_path='/--do--not--ever--use--this--') + self.app.config['UPLOAD_FOLDER'] = '/tmp/' + self.user = sysconfig.getHTTPAuth() + self.auth = NoAuth() + if self.user is not None: + self.auth = HTTPBasicAuth() + @self.auth.get_password + def check_password(username): + if self.user['user'] == username: + return self.user['password'] + return None + else: + logging.info('No http-auth.json found, disabling http authentication') + + logging.getLogger('werkzeug').setLevel(logging.ERROR) + logging.getLogger('oauthlib').setLevel(logging.ERROR) + logging.getLogger('urllib3').setLevel(logging.ERROR) + + self.app.error_handler_spec = {None: {None: {Exception: self._showException}}} + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + self.app.secret_key = os.urandom(24) + self._registerHandlers() + self.authmethod = self.auth.login_required(lambda: None) + self.app.after_request(self._nocache) + self.app.before_request(self._logincheck) + + def _logincheck(self): + if not request.endpoint: + return + + return self.authmethod() + + def start(self): + if self.run_async: + self.start() + else: + self.run() + + def stop(self): + try: + func = request.environ.get('werkzeug.server.shutdown') + if func: + func() + return True + else: + logging.error('Unable to stop webserver, cannot find shutdown() function') + return False + except: + # We're not running with request, so... + raise RuntimeError('Server shutdown') + + def run(self): + try: + self.app.run(debug=False, port=self.port, host=self.listen) + except RuntimeError as msg: + if str(msg) == "Server shutdown": + pass # or whatever you want to do when the server goes down + else: + raise RuntimeError(msg) + + def _nocache(self, r): + r.headers["Pragma"] = "no-cache" + r.headers["Expires"] = "0" + r.headers['Cache-Control'] = 'public, max-age=0' + return r + + def _showException(self, e): + if isinstance(e, HTTPException): + code = e.code + message = str(e) + else: + code = 500 + #exc_type, exc_value, exc_traceback = sys.exc_info() + lines = traceback.format_exc().splitlines() + #issue = lines[-1] + message = ''' Internal error

Uh oh, something went wrong...

Please go to github and see if this is a known issue, if not, feel free to file a new issue with the following information:
'''
-      for line in lines:
-        message += line + '\n'
-      message += '''
+ for line in lines: + message += line + '\n' + message += ''' Thank you for your patience ''' - return message, code - - def _instantiate(self, module, klass): - module = importlib.import_module('routes.' + module) - my_class = getattr(module, klass) - return my_class - - def registerHandler(self, route): - route._assignServer(self) - for mapping in route._MAPPINGS: - if route.SIMPLE: - logging.info('Registering URL %s to %s (simple)', mapping._URL, route.__class__.__name__) - else: - logging.info('Registering URL %s to %s', mapping._URL, route.__class__.__name__) - self.app.add_url_rule(mapping._URL, mapping._URL, route, methods=mapping._METHODS, defaults=mapping._DEFAULTS) - - def _registerHandlers(self): - for item in os.listdir('routes'): - if os.path.isfile('routes/' + item) and item.endswith('.py') and item != 'baseroute.py': - with open('routes/' + item, 'r') as f: - for line in f: - line = line.strip() - if line.startswith('class ') and line.endswith('(BaseRoute):'): - m = re.search('class +([^\(]+)\(', line) - if m is not None: - klass = self._instantiate(item[0:-3], m.group(1)) - if klass.SIMPLE: - try: - route = eval('klass()') - self.registerHandler(route) - except: - logging.exception('Failed to create route for %s' % item) - break + return message, code + + def _instantiate(self, module, klass): + module = importlib.import_module('routes.' + module) + my_class = getattr(module, klass) + return my_class + + def registerHandler(self, route): + route._assignServer(self) + for mapping in route._MAPPINGS: + if route.SIMPLE: + logging.info('Registering URL %s to %s (simple)', mapping._URL, route.__class__.__name__) + else: + logging.info('Registering URL %s to %s', mapping._URL, route.__class__.__name__) + self.app.add_url_rule(mapping._URL, mapping._URL, route, + methods=mapping._METHODS, defaults=mapping._DEFAULTS) + + def _registerHandlers(self): + for item in os.listdir('routes'): + if os.path.isfile('routes/' + item) and item.endswith('.py') and item != 'baseroute.py': + with open('routes/' + item, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('class ') and line.endswith('(BaseRoute):'): + m = re.search('class +([^\(]+)\(', line) + if m is not None: + klass = self._instantiate(item[0:-3], m.group(1)) + if klass.SIMPLE: + try: + route = eval('klass()') + self.registerHandler(route) + except: + logging.exception('Failed to create route for %s' % item) + break diff --git a/modules/servicemanager.py b/modules/servicemanager.py index 7bd3102..d74d27a 100755 --- a/modules/servicemanager.py +++ b/modules/servicemanager.py @@ -27,453 +27,456 @@ from modules.path import path from services.base import BaseService + class ServiceManager: - def __init__(self, settings, cacheMgr): - self._SETTINGS = settings - self._CACHEMGR = cacheMgr - - svc_folder = os.path.join(path.CONFIGFOLDER, 'services') - if not os.path.exists(svc_folder): - os.mkdir(svc_folder) - - self._BASEDIR = svc_folder - self._SVC_INDEX = {} # Holds all detected services - self._SERVICES = {} - self._CONFIGFILE = os.path.join(self._BASEDIR, 'services.json') - - # Logs services that appear to have no images or only images that have already been displayed - # memoryForgetAll will be called when all images of every services have been displayed - self._OUT_OF_IMAGES = [] - - # Logs the sequence in which services are being used - # useful for displaying previous images - self._HISTORY = [] - - # Tracks current service showing the image - self.currentService = None - - # Track configuration changes - self.configChanges = 0 - - self._detectServices() - self._load() - - # Translate old config into new - self._migrate() - - def _instantiate(self, module, klass): - module = importlib.import_module('services.' + module) - my_class = getattr(module, klass) - return my_class - - def _detectServices(self): - for item in os.listdir('services'): - if os.path.isfile('services/' + item) and item.startswith('svc_') and item.endswith('.py'): - with open('services/' + item, 'r') as f: - for line in f: - line = line.strip() - if line.startswith('class ') and line.endswith('(BaseService):'): - m = re.search('class +([^\(]+)\(', line) - if m is not None: - klass = self._instantiate(item[0:-3], m.group(1)) - logging.info('Loading service %s from %s', klass.__name__, item) - self._SVC_INDEX[m.group(1)] = {'id' : klass.SERVICE_ID, 'name' : klass.SERVICE_NAME, 'module' : item[0:-3], 'class' : m.group(1), 'deprecated' : klass.SERVICE_DEPRECATED} - break - - def _deletefolder(self, folder): - try: - shutil.rmtree(folder) - except: - logging.exception('Failed to delete "%s"', folder) - - def _resolveService(self, id): - for svc in self._SVC_INDEX: - if self._SVC_INDEX[svc]['id'] == id: - return svc - - return None - - def listServices(self): - result = [] - # Make sure it retains the ID sort order - for key, value in sorted(iter(self._SVC_INDEX.items()), key=lambda k_v: (k_v[1]['id'],k_v[0])): - result.append(self._SVC_INDEX[key]) - return result; - - def _save(self): - data = [] - for k in self._SERVICES: - svc = self._SERVICES[k] - data.append({'type' : svc['service'].SERVICE_ID, 'id' : svc['id'], 'name' : svc['name']}) - with open(self._CONFIGFILE, 'w') as f: - json.dump(data, f) - - def _load(self): - if not os.path.exists(self._CONFIGFILE): - return - try: - with open(self._CONFIGFILE, 'r') as f: - data = json.load(f) - except: - logging.error('%s is corrupt, skipping' % self._CONFIGFILE) - os.unlink(self._CONFIGFILE) - return - - # Instantiate the services - for entry in data: - svcname = self._resolveService(entry['type']) - if svcname is None: - logging.error('Cannot resolve service type %d, skipping', entry['type']) - continue - klass = self._instantiate(self._SVC_INDEX[svcname]['module'], self._SVC_INDEX[svcname]['class']) - if klass: - svc = eval("klass(self._BASEDIR, entry['id'], entry['name'])") - svc.setCacheManager(self._CACHEMGR) - self._SERVICES[svc.getId()] = {'service' : svc, 'id' : svc.getId(), 'name' : svc.getName()} - - def _hash(self, text): - return hashlib.sha1(text).hexdigest() - - def _configChanged(self): - self.configChanges += 1 - - def getConfigChange(self): - return self.configChanges - - def addService(self, type, name): - svcname = self._resolveService(type) - if svcname is None: - return None - - genid = self._hash("%s-%f-%d" % (name, time.time(), len(self._SERVICES))) - klass = self._instantiate(self._SVC_INDEX[svcname]['module'], self._SVC_INDEX[svcname]['class']) - if klass: - svc = eval("klass(self._BASEDIR, genid, name)") - svc.setCacheManager(self._CACHEMGR) - self._SERVICES[genid] = {'service' : svc, 'id' : svc.getId(), 'name' : svc.getName()} - self._save() - self._configChanged() - return genid - return None - - def renameService(self, id, newName): - if id not in self._SERVICES: - return False - self._SERVICES[id]['service'].setName(newName) - self._SERVICES[id]['name'] = newName - self._save() - return True - - def deleteService(self, id): - if id not in self._SERVICES: - return - - self._HISTORY = [h for h in self._HISTORY if h != self._SERVICES[id]['service']] - del self._SERVICES[id] - self._deletefolder(os.path.join(self._BASEDIR, id)) - self._configChanged() - self._save() - - def oauthCallback(self, request): - state = request.args.get('state').split('-') - if len(state) < 3: - logging.error('Invalid callback, need correct state to redirect to OAuth session') - return False - - if state[2] not in self._SERVICES: - return False - svc = self._SERVICES[state[2]]['service'] - svc.finishOAuth(request.url) - return True - - def oauthConfig(self, service, data): - if service not in self._SERVICES: - return False - svc = self._SERVICES[service]['service'] - return svc.setOAuthConfig(data) - - def oauthStart(self, service): - if service not in self._SERVICES: - return None - svc = self._SERVICES[service]['service'] - return svc.startOAuth() - - def getServiceConfigurationFields(self, service): - if service not in self._SERVICES: - return {} - svc = self._SERVICES[service]['service'] - if not svc.hasConfiguration(): - return {} - return svc.getConfigurationFields() - - def getServiceConfiguration(self, service): - if service not in self._SERVICES: - return {} - svc = self._SERVICES[service]['service'] - if not svc.hasConfiguration(): - return {} - return svc.getConfiguration() - - def setServiceConfiguration(self, service, config): - if service not in self._SERVICES: - return False - svc = self._SERVICES[service]['service'] - if not svc.hasConfiguration(): - return False - if not svc.validateConfiguration(config): - return False - svc.setConfiguration(config) - return True - - def getServiceKeywords(self, service): - if service not in self._SERVICES: - return None - svc = self._SERVICES[service]['service'] - if not svc.needKeywords(): - return None - return svc.getKeywords() - - def addServiceKeywords(self, service, keywords): - if service not in self._SERVICES: - return {'error' : 'No such service'} - svc = self._SERVICES[service]['service'] - if not svc.needKeywords(): - return {'error' : 'Service does not use keywords'} - if svc in self._OUT_OF_IMAGES: - self._OUT_OF_IMAGES.remove(svc) - self._configChanged() - return svc.addKeywords(keywords) - - def removeServiceKeywords(self, service, index): - if service not in self._SERVICES: - logging.error('removeServiceKeywords: No such service') - return False - svc = self._SERVICES[service]['service'] - if not svc.needKeywords(): - logging.error('removeServiceKeywords: Does not use keywords') - return False - self._configChanged() - return svc.removeKeywords(index) - - def sourceServiceKeywords(self, service, index): - if service not in self._SERVICES: - return None - svc = self._SERVICES[service]['service'] - if not svc.hasKeywordSourceUrl(): - logging.error('Service does not support sourceUrl') - return None - return svc.getKeywordSourceUrl(index) - - def detailsServiceKeywords(self, service, index): - if service not in self._SERVICES: - return None - svc = self._SERVICES[service]['service'] - if not svc.hasKeywordDetails(): - logging.error('Service does not support keyword details') - return None - return svc.getKeywordDetails(index) - - def helpServiceKeywords(self, service): - if service not in self._SERVICES: - return False - svc = self._SERVICES[service]['service'] - if not svc.needKeywords(): - return None - return svc.helpKeywords() - - def getServiceState(self, id): - if id not in self._SERVICES: - return None - svc = self._SERVICES[id]['service'] - # Find out if service is ready - return svc.updateState() - - def getServiceStateText(self, id): - state = self.getServiceState(id) - if state == BaseService.STATE_DO_OAUTH: - return 'OAUTH' - elif state == BaseService.STATE_DO_CONFIG: - return 'CONFIG' - elif state == BaseService.STATE_NEED_KEYWORDS: - return 'NEED_KEYWORDS' - elif state == BaseService.STATE_NO_IMAGES: - return 'NO_IMAGES' - elif state == BaseService.STATE_READY: - return 'READY' - return 'ERROR' - - def getAllServiceStates(self): - serviceStates = [] - for id in self._SERVICES: - svc = self._SERVICES[id]['service'] - name = svc.getName() - state = self.getServiceStateText(id) - additionalInfo = svc.explainState() - serviceStates.append((name, state, additionalInfo)) - return serviceStates - - def _migrate(self): - if os.path.exists(path.CONFIGFOLDER + '/oauth.json'): - logging.info('Migrating old setup to new service layout') - from services.svc_picasaweb import PicasaWeb - - id = self.addService(PicasaWeb.SERVICE_ID, 'PicasaWeb') - svc = self._SERVICES[id]['service'] - - # Migrate the oauth configuration - with open(path.CONFIGFOLDER + '/oauth.json') as f: - data = json.load(f) - if 'web' in data: # if someone added it via command-line - data = data['web'] - svc.setOAuthConfig(data) - svc.migrateOAuthToken(self._SETTINGS.get('oauth_token')) - - os.unlink(path.CONFIGFOLDER + '/oauth.json') - self._SETTINGS.set('oauth_token', '') - - # Migrate keywords - keywords = self._SETTINGS.getUser('keywords') - for keyword in keywords: - svc.addKeywords(keyword) - - # Blank out the old keywords since they were migrated - self._SETTINGS.delete('keywords') - self._SETTINGS.delete('keywords', userField=True) - self._SETTINGS.save() - - def getLastUsedServiceName(self): - if self.currentService is None: - return "" - return self._SERVICES[self.currentService]['service'].getName() - - def getServices(self, readyOnly=False): - result = [] - for k in self._SERVICES: - if readyOnly and self.getServiceState(k) != BaseService.STATE_READY: - continue - svc = self._SERVICES[k] - result.append({ - 'name' : svc['service'].getName(), - 'service' : svc['service'].SERVICE_ID, - 'id' : k, - 'state' : self.getServiceStateText(k), - 'useKeywords' : svc['service'].needKeywords(), - 'hasSourceUrl' : svc['service'].hasKeywordSourceUrl(), - 'hasDetails' : svc['service'].hasKeywordDetails(), - 'messages' : svc['service'].getMessages(), - }) - return result - - def _getOffsetService(self, availableServices, lastService, offset): - # Just a helper function to figure out what the next/previous service is - for i, _svc in enumerate(availableServices): - if self._SERVICES[_svc['id']]['service'] == lastService: - key = availableServices[(i+offset) % len(availableServices)]['id'] - return self._SERVICES[key]['service'] - return lastService - - def selectRandomService(self, services): - # select service at random but weighted by the number of images each service provides - numImages = [self._SERVICES[s['id']]['service'].getImagesTotal() for s in services] - totalImages = sum(numImages) - if totalImages == 0: - return 0 - i = helper.getWeightedRandomIndex(numImages) - return services[i]['id'] - - def chooseService(self, randomize, retry=False): - result = None - availableServices = self.getServices(readyOnly=True) - if len(availableServices) == 0: - return None - - if randomize: - availableServices = [s for s in availableServices if self._SERVICES[s['id']]['service'].getImagesRemaining() > 0] - if len(availableServices) > 0: - logging.debug('Found %d services with images' % len(availableServices)) - key = self.selectRandomService(availableServices) - result = self._SERVICES[key]['service'] - else: - offset = 0 - # Find where to start - for s in range(0, len(availableServices)): - if availableServices[s]['id'] == self.currentService: - offset = s - break - # Next, pick the service which has photos - for s in range(0, len(availableServices)): - index = (offset + s) % len(availableServices) - svc = self._SERVICES[availableServices[index]['id']]['service'] - if svc.getImagesRemaining() > 0: - result = svc - break - - # Oh snap, out of images, reset memory and try again - if result is None and not retry: - logging.info('All images have been shown, resetting counters') - self.memoryForgetAll() - return self.chooseService(randomize, retry=True) # Avoid calling us forever - else: - logging.debug('Picked %s which has %d images left to show', result.getName(), result.getImagesRemaining()) - return result - - def expireStaleKeywords(self): - maxage = self._SETTINGS.getUser('refresh') - for key in self._SERVICES: - svc = self._SERVICES[key]["service"] - for k in svc.getKeywords(): - if svc.freshnessImagesFor(k) < maxage: - continue - logging.info('Expire is set to %dh, expiring %s which was %d hours old', maxage, k, svc.freshnessImagesFor(k)) - svc._clearImagesFor(k) - - def getTotalImageCount(self): - services = self.getServices(readyOnly=True) - return sum([self._SERVICES[s['id']]['service'].getImagesTotal() for s in services]) - - def servicePrepareNextItem(self, destinationDir, supportedMimeTypes, displaySize, randomize): - # We should expire any old index if setting is active - if self._SETTINGS.getUser('refresh') > 0: - self.expireStaleKeywords() - - svc = self.chooseService(randomize) - if svc is None: - return None - result = svc.prepareNextItem(destinationDir, supportedMimeTypes, displaySize, randomize) - if result is None: - logging.warning('prepareNextItem for %s came back with None', svc.getName()) - elif result.error is not None: - logging.warning('prepareNextItem for %s came back with an error: %s', svc.getName(), result.error) - return result - - def hasKeywords(self): - # Check any and all services to see if any is ready and if they have keywords - for k in self._SERVICES: - if self.getServiceStateText(k) != 'READY': - continue - words = self.getServiceKeywords(k) - if words is not None and len(words) > 0: + def __init__(self, settings, cacheMgr): + self._SETTINGS = settings + self._CACHEMGR = cacheMgr + + svc_folder = os.path.join(path.CONFIGFOLDER, 'services') + if not os.path.exists(svc_folder): + os.mkdir(svc_folder) + + self._BASEDIR = svc_folder + self._SVC_INDEX = {} # Holds all detected services + self._SERVICES = {} + self._CONFIGFILE = os.path.join(self._BASEDIR, 'services.json') + + # Logs services that appear to have no images or only images that have already been displayed + # memoryForgetAll will be called when all images of every services have been displayed + self._OUT_OF_IMAGES = [] + + # Logs the sequence in which services are being used + # useful for displaying previous images + self._HISTORY = [] + + # Tracks current service showing the image + self.currentService = None + + # Track configuration changes + self.configChanges = 0 + + self._detectServices() + self._load() + + # Translate old config into new + self._migrate() + + def _instantiate(self, module, klass): + module = importlib.import_module('services.' + module) + my_class = getattr(module, klass) + return my_class + + def _detectServices(self): + for item in os.listdir('services'): + if os.path.isfile('services/' + item) and item.startswith('svc_') and item.endswith('.py'): + with open('services/' + item, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('class ') and line.endswith('(BaseService):'): + m = re.search('class +([^\(]+)\(', line) + if m is not None: + klass = self._instantiate(item[0:-3], m.group(1)) + logging.info('Loading service %s from %s', klass.__name__, item) + self._SVC_INDEX[m.group(1)] = {'id': klass.SERVICE_ID, 'name': klass.SERVICE_NAME, + 'module': item[0:-3], 'class': m.group(1), 'deprecated': klass.SERVICE_DEPRECATED} + break + + def _deletefolder(self, folder): + try: + shutil.rmtree(folder) + except: + logging.exception('Failed to delete "%s"', folder) + + def _resolveService(self, id): + for svc in self._SVC_INDEX: + if self._SVC_INDEX[svc]['id'] == id: + return svc + + return None + + def listServices(self): + result = [] + # Make sure it retains the ID sort order + for key, value in sorted(iter(self._SVC_INDEX.items()), key=lambda k_v: (k_v[1]['id'], k_v[0])): + result.append(self._SVC_INDEX[key]) + return result + + def _save(self): + data = [] + for k in self._SERVICES: + svc = self._SERVICES[k] + data.append({'type': svc['service'].SERVICE_ID, 'id': svc['id'], 'name': svc['name']}) + with open(self._CONFIGFILE, 'w') as f: + json.dump(data, f) + + def _load(self): + if not os.path.exists(self._CONFIGFILE): + return + try: + with open(self._CONFIGFILE, 'r') as f: + data = json.load(f) + except: + logging.error('%s is corrupt, skipping' % self._CONFIGFILE) + os.unlink(self._CONFIGFILE) + return + + # Instantiate the services + for entry in data: + svcname = self._resolveService(entry['type']) + if svcname is None: + logging.error('Cannot resolve service type %d, skipping', entry['type']) + continue + klass = self._instantiate(self._SVC_INDEX[svcname]['module'], self._SVC_INDEX[svcname]['class']) + if klass: + svc = eval("klass(self._BASEDIR, entry['id'], entry['name'])") + svc.setCacheManager(self._CACHEMGR) + self._SERVICES[svc.getId()] = {'service': svc, 'id': svc.getId(), 'name': svc.getName()} + + def _hash(self, text): + return hashlib.sha1(text).hexdigest() + + def _configChanged(self): + self.configChanges += 1 + + def getConfigChange(self): + return self.configChanges + + def addService(self, type, name): + svcname = self._resolveService(type) + if svcname is None: + return None + + genid = self._hash("%s-%f-%d" % (name, time.time(), len(self._SERVICES))) + klass = self._instantiate(self._SVC_INDEX[svcname]['module'], self._SVC_INDEX[svcname]['class']) + if klass: + svc = eval("klass(self._BASEDIR, genid, name)") + svc.setCacheManager(self._CACHEMGR) + self._SERVICES[genid] = {'service': svc, 'id': svc.getId(), 'name': svc.getName()} + self._save() + self._configChanged() + return genid + return None + + def renameService(self, id, newName): + if id not in self._SERVICES: + return False + self._SERVICES[id]['service'].setName(newName) + self._SERVICES[id]['name'] = newName + self._save() return True - return False - - def hasReadyServices(self): - for k in self._SERVICES: - if self.getServiceStateText(k) != 'READY': - continue - return True - return False - - def memoryForgetAll(self): - logging.info("Photoframe's memory was reset. Already displayed images will be shown again!") - for key in self._SERVICES: - svc = self._SERVICES[key]["service"] - for k in svc.getKeywords(): - logging.info('%s was %d hours old when we refreshed' % (k, svc.freshnessImagesFor(k))) - svc._clearImagesFor(k) - def nextAlbum(self): - return False + def deleteService(self, id): + if id not in self._SERVICES: + return + + self._HISTORY = [h for h in self._HISTORY if h != self._SERVICES[id]['service']] + del self._SERVICES[id] + self._deletefolder(os.path.join(self._BASEDIR, id)) + self._configChanged() + self._save() + + def oauthCallback(self, request): + state = request.args.get('state').split('-') + if len(state) < 3: + logging.error('Invalid callback, need correct state to redirect to OAuth session') + return False + + if state[2] not in self._SERVICES: + return False + svc = self._SERVICES[state[2]]['service'] + svc.finishOAuth(request.url) + return True - def prevAlbum(self): - return False + def oauthConfig(self, service, data): + if service not in self._SERVICES: + return False + svc = self._SERVICES[service]['service'] + return svc.setOAuthConfig(data) + + def oauthStart(self, service): + if service not in self._SERVICES: + return None + svc = self._SERVICES[service]['service'] + return svc.startOAuth() + + def getServiceConfigurationFields(self, service): + if service not in self._SERVICES: + return {} + svc = self._SERVICES[service]['service'] + if not svc.hasConfiguration(): + return {} + return svc.getConfigurationFields() + + def getServiceConfiguration(self, service): + if service not in self._SERVICES: + return {} + svc = self._SERVICES[service]['service'] + if not svc.hasConfiguration(): + return {} + return svc.getConfiguration() + + def setServiceConfiguration(self, service, config): + if service not in self._SERVICES: + return False + svc = self._SERVICES[service]['service'] + if not svc.hasConfiguration(): + return False + if not svc.validateConfiguration(config): + return False + svc.setConfiguration(config) + return True + def getServiceKeywords(self, service): + if service not in self._SERVICES: + return None + svc = self._SERVICES[service]['service'] + if not svc.needKeywords(): + return None + return svc.getKeywords() + + def addServiceKeywords(self, service, keywords): + if service not in self._SERVICES: + return {'error': 'No such service'} + svc = self._SERVICES[service]['service'] + if not svc.needKeywords(): + return {'error': 'Service does not use keywords'} + if svc in self._OUT_OF_IMAGES: + self._OUT_OF_IMAGES.remove(svc) + self._configChanged() + return svc.addKeywords(keywords) + + def removeServiceKeywords(self, service, index): + if service not in self._SERVICES: + logging.error('removeServiceKeywords: No such service') + return False + svc = self._SERVICES[service]['service'] + if not svc.needKeywords(): + logging.error('removeServiceKeywords: Does not use keywords') + return False + self._configChanged() + return svc.removeKeywords(index) + + def sourceServiceKeywords(self, service, index): + if service not in self._SERVICES: + return None + svc = self._SERVICES[service]['service'] + if not svc.hasKeywordSourceUrl(): + logging.error('Service does not support sourceUrl') + return None + return svc.getKeywordSourceUrl(index) + + def detailsServiceKeywords(self, service, index): + if service not in self._SERVICES: + return None + svc = self._SERVICES[service]['service'] + if not svc.hasKeywordDetails(): + logging.error('Service does not support keyword details') + return None + return svc.getKeywordDetails(index) + + def helpServiceKeywords(self, service): + if service not in self._SERVICES: + return False + svc = self._SERVICES[service]['service'] + if not svc.needKeywords(): + return None + return svc.helpKeywords() + + def getServiceState(self, id): + if id not in self._SERVICES: + return None + svc = self._SERVICES[id]['service'] + # Find out if service is ready + return svc.updateState() + + def getServiceStateText(self, id): + state = self.getServiceState(id) + if state == BaseService.STATE_DO_OAUTH: + return 'OAUTH' + elif state == BaseService.STATE_DO_CONFIG: + return 'CONFIG' + elif state == BaseService.STATE_NEED_KEYWORDS: + return 'NEED_KEYWORDS' + elif state == BaseService.STATE_NO_IMAGES: + return 'NO_IMAGES' + elif state == BaseService.STATE_READY: + return 'READY' + return 'ERROR' + + def getAllServiceStates(self): + serviceStates = [] + for id in self._SERVICES: + svc = self._SERVICES[id]['service'] + name = svc.getName() + state = self.getServiceStateText(id) + additionalInfo = svc.explainState() + serviceStates.append((name, state, additionalInfo)) + return serviceStates + + def _migrate(self): + if os.path.exists(path.CONFIGFOLDER + '/oauth.json'): + logging.info('Migrating old setup to new service layout') + from services.svc_picasaweb import PicasaWeb + + id = self.addService(PicasaWeb.SERVICE_ID, 'PicasaWeb') + svc = self._SERVICES[id]['service'] + + # Migrate the oauth configuration + with open(path.CONFIGFOLDER + '/oauth.json') as f: + data = json.load(f) + if 'web' in data: # if someone added it via command-line + data = data['web'] + svc.setOAuthConfig(data) + svc.migrateOAuthToken(self._SETTINGS.get('oauth_token')) + + os.unlink(path.CONFIGFOLDER + '/oauth.json') + self._SETTINGS.set('oauth_token', '') + + # Migrate keywords + keywords = self._SETTINGS.getUser('keywords') + for keyword in keywords: + svc.addKeywords(keyword) + + # Blank out the old keywords since they were migrated + self._SETTINGS.delete('keywords') + self._SETTINGS.delete('keywords', userField=True) + self._SETTINGS.save() + + def getLastUsedServiceName(self): + if self.currentService is None: + return "" + return self._SERVICES[self.currentService]['service'].getName() + + def getServices(self, readyOnly=False): + result = [] + for k in self._SERVICES: + if readyOnly and self.getServiceState(k) != BaseService.STATE_READY: + continue + svc = self._SERVICES[k] + result.append({ + 'name': svc['service'].getName(), + 'service': svc['service'].SERVICE_ID, + 'id': k, + 'state': self.getServiceStateText(k), + 'useKeywords': svc['service'].needKeywords(), + 'hasSourceUrl': svc['service'].hasKeywordSourceUrl(), + 'hasDetails': svc['service'].hasKeywordDetails(), + 'messages': svc['service'].getMessages(), + }) + return result + + def _getOffsetService(self, availableServices, lastService, offset): + # Just a helper function to figure out what the next/previous service is + for i, _svc in enumerate(availableServices): + if self._SERVICES[_svc['id']]['service'] == lastService: + key = availableServices[(i+offset) % len(availableServices)]['id'] + return self._SERVICES[key]['service'] + return lastService + + def selectRandomService(self, services): + # select service at random but weighted by the number of images each service provides + numImages = [self._SERVICES[s['id']]['service'].getImagesTotal() for s in services] + totalImages = sum(numImages) + if totalImages == 0: + return 0 + i = helper.getWeightedRandomIndex(numImages) + return services[i]['id'] + + def chooseService(self, randomize, retry=False): + result = None + availableServices = self.getServices(readyOnly=True) + if len(availableServices) == 0: + return None + + if randomize: + availableServices = [s for s in availableServices if self._SERVICES[s['id']] + ['service'].getImagesRemaining() > 0] + if len(availableServices) > 0: + logging.debug('Found %d services with images' % len(availableServices)) + key = self.selectRandomService(availableServices) + result = self._SERVICES[key]['service'] + else: + offset = 0 + # Find where to start + for s in range(0, len(availableServices)): + if availableServices[s]['id'] == self.currentService: + offset = s + break + # Next, pick the service which has photos + for s in range(0, len(availableServices)): + index = (offset + s) % len(availableServices) + svc = self._SERVICES[availableServices[index]['id']]['service'] + if svc.getImagesRemaining() > 0: + result = svc + break + + # Oh snap, out of images, reset memory and try again + if result is None and not retry: + logging.info('All images have been shown, resetting counters') + self.memoryForgetAll() + return self.chooseService(randomize, retry=True) # Avoid calling us forever + else: + logging.debug('Picked %s which has %d images left to show', result.getName(), result.getImagesRemaining()) + return result + + def expireStaleKeywords(self): + maxage = self._SETTINGS.getUser('refresh') + for key in self._SERVICES: + svc = self._SERVICES[key]["service"] + for k in svc.getKeywords(): + if svc.freshnessImagesFor(k) < maxage: + continue + logging.info('Expire is set to %dh, expiring %s which was %d hours old', + maxage, k, svc.freshnessImagesFor(k)) + svc._clearImagesFor(k) + + def getTotalImageCount(self): + services = self.getServices(readyOnly=True) + return sum([self._SERVICES[s['id']]['service'].getImagesTotal() for s in services]) + + def servicePrepareNextItem(self, destinationDir, supportedMimeTypes, displaySize, randomize): + # We should expire any old index if setting is active + if self._SETTINGS.getUser('refresh') > 0: + self.expireStaleKeywords() + + svc = self.chooseService(randomize) + if svc is None: + return None + result = svc.prepareNextItem(destinationDir, supportedMimeTypes, displaySize, randomize) + if result is None: + logging.warning('prepareNextItem for %s came back with None', svc.getName()) + elif result.error is not None: + logging.warning('prepareNextItem for %s came back with an error: %s', svc.getName(), result.error) + return result + + def hasKeywords(self): + # Check any and all services to see if any is ready and if they have keywords + for k in self._SERVICES: + if self.getServiceStateText(k) != 'READY': + continue + words = self.getServiceKeywords(k) + if words is not None and len(words) > 0: + return True + return False + + def hasReadyServices(self): + for k in self._SERVICES: + if self.getServiceStateText(k) != 'READY': + continue + return True + return False + + def memoryForgetAll(self): + logging.info("Photoframe's memory was reset. Already displayed images will be shown again!") + for key in self._SERVICES: + svc = self._SERVICES[key]["service"] + for k in svc.getKeywords(): + logging.info('%s was %d hours old when we refreshed' % (k, svc.freshnessImagesFor(k))) + svc._clearImagesFor(k) + + def nextAlbum(self): + return False + + def prevAlbum(self): + return False diff --git a/modules/settings.py b/modules/settings.py index 0f471ab..a336477 100755 --- a/modules/settings.py +++ b/modules/settings.py @@ -19,160 +19,163 @@ import random from .path import path + class settings: - DEPRECATED_USER = ['resolution'] - DEPRECATED_SYSTEM = ['colortemp-script', 'local-ip'] - - def __init__(self): - self.settings = { - 'oauth_token' : None, - 'oauth_state' : None, - 'tempfolder' : '/tmp/', - 'colortemp' : None, - 'cfg' : None - } - self.userDefaults() - - def userDefaults(self): - self.settings['cfg'] = { - 'width' : 1920, - 'height' : 1080, - 'depth' : 32, - 'tvservice' : 'DMT 82 DVI', - 'timezone' : '', - 'interval' : 60, # Delay in seconds between images (minimum) - 'display-off' : 22, # What hour (24h) to disable display and sleep - 'display-on' : 4, # What hour (24h) to enable display and continue - 'refresh' : 0, # After how many hours we should force reload of image lists from server (zero means when all is shown) - 'autooff-lux' : 0.01, - 'autooff-time' : 0, - 'powersave' : '', - 'shutdown-pin' : 3, - 'display-driver' : 'none', - 'display-special' : None, - 'imagesizing' : 'blur', - 'force_orientation' : 0, - 'randomize_images' : 1, - 'enable-cache' : 1, - 'offline-behavior' : 'wait', # wait = wait for network, ignore = try next (rely on cache or non-internet connections) - } - - def load(self): - if os.path.exists(path.CONFIGFILE): - with open(path.CONFIGFILE) as f: + DEPRECATED_USER = ['resolution'] + DEPRECATED_SYSTEM = ['colortemp-script', 'local-ip'] + + def __init__(self): + self.settings = { + 'oauth_token': None, + 'oauth_state': None, + 'tempfolder': '/tmp/', + 'colortemp': None, + 'cfg': None + } + self.userDefaults() + + def userDefaults(self): + self.settings['cfg'] = { + 'width': 1920, + 'height': 1080, + 'depth': 32, + 'tvservice': 'DMT 82 DVI', + 'timezone': '', + 'interval': 60, # Delay in seconds between images (minimum) + 'display-off': 22, # What hour (24h) to disable display and sleep + 'display-on': 4, # What hour (24h) to enable display and continue + # After how many hours we should force reload of image lists from server (zero means when all is shown) + 'refresh': 0, + 'autooff-lux': 0.01, + 'autooff-time': 0, + 'powersave': '', + 'shutdown-pin': 3, + 'display-driver': 'none', + 'display-special': None, + 'imagesizing': 'blur', + 'force_orientation': 0, + 'randomize_images': 1, + 'enable-cache': 1, + # wait = wait for network, ignore = try next (rely on cache or non-internet connections) + 'offline-behavior': 'wait', + } + + def load(self): + if os.path.exists(path.CONFIGFILE): + with open(path.CONFIGFILE) as f: + try: + # A bit messy, but it should allow new defaults to be added + # to old configurations. + tmp = self.settings['cfg'] + self.settings = json.load(f) + tmp2 = self.settings['cfg'] + self.settings['cfg'] = tmp + self.settings['cfg'].update(tmp2) + + if 'refresh-content' in self.settings['cfg']: + self.settings['cfg']['refresh'] = self.settings['cfg']['refresh-content'] + self.settings['cfg'].pop('refresh-content', None) + + # Remove deprecated fields + for field in settings.DEPRECATED_USER: + self.settings['cfg'].pop(field, None) + for field in settings.DEPRECATED_SYSTEM: + self.settings.pop(field, None) + + # Also, we need to iterate the settings and make sure numbers and floats are + # that, and not strings (which the old version did) + for k in self.settings['cfg']: + self.settings['cfg'][k] = self.convertToNative(self.settings['cfg'][k]) + # Lastly, correct the tvservice field, should be "TEXT NUMBER TEXT" + # This is a little bit of a cheat + parts = self.settings['cfg']['tvservice'].split(' ') + if len(parts) == 3 and type(self.convertToNative(parts[1])) != int and type(self.convertToNative(parts[2])) == int: + logging.debug('Reordering tvservice value due to old bug') + self.settings['cfg']['tvservice'] = "%s %s %s" % (parts[0], parts[2], parts[1]) + self.save() + except: + logging.exception('Failed to load settings.json, corrupt file?') + return False + # make sure old settings.json files are still compatible and get updated with new keys + if "cachefolder" not in list(self.settings.keys()): + self.settings["cachefolder"] = path.CACHEFOLDER + return True + else: + return False + + def save(self): + with open(path.CONFIGFILE, 'w') as f: + json.dump(self.settings, f) + + def convertToNative(self, value): + try: + if '.' in value: + return float(value) + return int(value) + except: + return value + + def setUser(self, key, value): + self.settings['cfg'][key] = self.convertToNative(value) + + def getUser(self, key=None): + if key is None: + return self.settings['cfg'] + + if key in self.settings['cfg']: + return self.settings['cfg'][key] + logging.warning('Trying to access non-existent user config key "%s"' % key) try: - # A bit messy, but it should allow new defaults to be added - # to old configurations. - tmp = self.settings['cfg'] - self.settings = json.load(f) - tmp2 = self.settings['cfg'] - self.settings['cfg'] = tmp - self.settings['cfg'].update(tmp2) - - if 'refresh-content' in self.settings['cfg']: - self.settings['cfg']['refresh'] = self.settings['cfg']['refresh-content'] - self.settings['cfg'].pop('refresh-content', None) - - # Remove deprecated fields - for field in settings.DEPRECATED_USER: - self.settings['cfg'].pop(field, None) - for field in settings.DEPRECATED_SYSTEM: - self.settings.pop(field, None) - - # Also, we need to iterate the settings and make sure numbers and floats are - # that, and not strings (which the old version did) - for k in self.settings['cfg']: - self.settings['cfg'][k] = self.convertToNative(self.settings['cfg'][k]) - # Lastly, correct the tvservice field, should be "TEXT NUMBER TEXT" - # This is a little bit of a cheat - parts = self.settings['cfg']['tvservice'].split(' ') - if len(parts) == 3 and type(self.convertToNative(parts[1])) != int and type(self.convertToNative(parts[2])) == int: - logging.debug('Reordering tvservice value due to old bug') - self.settings['cfg']['tvservice'] = "%s %s %s" % (parts[0], parts[2], parts[1]) - self.save() + a = 1 / 0 + a += 1 except: - logging.exception('Failed to load settings.json, corrupt file?') - return False - # make sure old settings.json files are still compatible and get updated with new keys - if "cachefolder" not in list(self.settings.keys()): - self.settings["cachefolder"] = path.CACHEFOLDER - return True - else: - return False - - def save(self): - with open(path.CONFIGFILE, 'w') as f: - json.dump(self.settings, f) - - def convertToNative(self, value): - try: - if '.' in value: - return float(value) - return int(value) - except: - return value - - def setUser(self, key, value): - self.settings['cfg'][key] = self.convertToNative(value) - - def getUser(self, key=None): - if key is None: - return self.settings['cfg'] - - if key in self.settings['cfg']: - return self.settings['cfg'][key] - logging.warning('Trying to access non-existent user config key "%s"' % key) - try: - a = 1 /0 - a += 1 - except: - logging.exception('Where did this come from??') - return None - - def addKeyword(self, keyword): - if keyword is None: - return False - keyword = keyword.strip() - if keyword not in self.settings['cfg']['keywords']: - self.settings['cfg']['keywords'].append(keyword.strip()) - return True - return False - - def removeKeyword(self, id): - if id < 0 or id >= len(self.settings['cfg']['keywords']): - return False - self.settings['cfg']['keywords'].pop(id) - if len(self.settings['cfg']['keywords']) == 0: - self.addKeyword('') - return True - - def getKeyword(self, id=None): - if id is None: - rnd = random.SystemRandom().randint(0, len(self.settings['cfg']['keywords'])-1) - return rnd # self.settings['cfg']['keywords'][rnd] - elif id >= 0 and id < len(self.settings['cfg']['keywords']): - return self.settings['cfg']['keywords'][id] - else: - return None - - def countKeywords(self): - return len(self.settings['cfg']['keywords']) - - def set(self, key, value): - self.settings[key] = self.convertToNative(value) - - def delete(self, key, userField=False): - if userField: - if key in self.settings['cfg']: - del self.settings['cfg'][key] - else: - if key in self.settings: - del self.settings[key] - - def get(self, key): - if key == 'colortemp-script': - return path.COLORMATCH - if key in self.settings: - return self.settings[key] - logging.warning('Trying to access non-existent config key "%s"' % key) - return None + logging.exception('Where did this come from??') + return None + + def addKeyword(self, keyword): + if keyword is None: + return False + keyword = keyword.strip() + if keyword not in self.settings['cfg']['keywords']: + self.settings['cfg']['keywords'].append(keyword.strip()) + return True + return False + + def removeKeyword(self, id): + if id < 0 or id >= len(self.settings['cfg']['keywords']): + return False + self.settings['cfg']['keywords'].pop(id) + if len(self.settings['cfg']['keywords']) == 0: + self.addKeyword('') + return True + + def getKeyword(self, id=None): + if id is None: + rnd = random.SystemRandom().randint(0, len(self.settings['cfg']['keywords'])-1) + return rnd # self.settings['cfg']['keywords'][rnd] + elif id >= 0 and id < len(self.settings['cfg']['keywords']): + return self.settings['cfg']['keywords'][id] + else: + return None + + def countKeywords(self): + return len(self.settings['cfg']['keywords']) + + def set(self, key, value): + self.settings[key] = self.convertToNative(value) + + def delete(self, key, userField=False): + if userField: + if key in self.settings['cfg']: + del self.settings['cfg'][key] + else: + if key in self.settings: + del self.settings[key] + + def get(self, key): + if key == 'colortemp-script': + return path.COLORMATCH + if key in self.settings: + return self.settings[key] + logging.warning('Trying to access non-existent config key "%s"' % key) + return None diff --git a/modules/shutdown.py b/modules/shutdown.py index d893f31..493b208 100644 --- a/modules/shutdown.py +++ b/modules/shutdown.py @@ -20,46 +20,47 @@ import socket import logging + class shutdown(Thread): - def __init__(self, usePIN=26): - Thread.__init__(self) - self.daemon = True - self.gpio = usePIN - self.void = open(os.devnull, 'wb') - self.client, self.server = socket.socketpair() - self.start() + def __init__(self, usePIN=26): + Thread.__init__(self) + self.daemon = True + self.gpio = usePIN + self.void = open(os.devnull, 'wb') + self.client, self.server = socket.socketpair() + self.start() - def stopmonitor(self): - self.client.close() + def stopmonitor(self): + self.client.close() - def run(self): - logging.info('GPIO shutdown can be triggered by GPIO %d', self.gpio) - # Shutdown can be initated from GPIO26 - poller = select.poll() - try: - with open('/sys/class/gpio/export', 'wb') as f: - f.write('%d' % self.gpio) - except: - # Usually it means we ran this before - pass - try: - with open('/sys/class/gpio/gpio%d/direction' % self.gpio, 'wb') as f: - f.write('in') - except: - logging.warn('Either no GPIO subsystem or no access') - return - with open('/sys/class/gpio/gpio%d/edge' % self.gpio, 'wb') as f: - f.write('both') - with open('/sys/class/gpio/gpio%d/active_low' % self.gpio, 'wb') as f: - f.write('1') - with open('/sys/class/gpio/gpio%d/value' % self.gpio, 'rb') as f: - f.read() - poller.register(f, select.POLLPRI) - poller.register(self.server, select.POLLHUP) - i = poller.poll(None) - for (fd, event) in i: - if f.fileno() == fd: - subprocess.call(['/sbin/poweroff'], stderr=self.void); - logging.debug('Shutdown GPIO triggered') - elif self.server.fileno() == fd: - logging.debug('Quitting shutdown manager') + def run(self): + logging.info('GPIO shutdown can be triggered by GPIO %d', self.gpio) + # Shutdown can be initated from GPIO26 + poller = select.poll() + try: + with open('/sys/class/gpio/export', 'wb') as f: + f.write('%d' % self.gpio) + except: + # Usually it means we ran this before + pass + try: + with open('/sys/class/gpio/gpio%d/direction' % self.gpio, 'wb') as f: + f.write('in') + except: + logging.warn('Either no GPIO subsystem or no access') + return + with open('/sys/class/gpio/gpio%d/edge' % self.gpio, 'wb') as f: + f.write('both') + with open('/sys/class/gpio/gpio%d/active_low' % self.gpio, 'wb') as f: + f.write('1') + with open('/sys/class/gpio/gpio%d/value' % self.gpio, 'rb') as f: + f.read() + poller.register(f, select.POLLPRI) + poller.register(self.server, select.POLLHUP) + i = poller.poll(None) + for (fd, event) in i: + if f.fileno() == fd: + subprocess.call(['/sbin/poweroff'], stderr=self.void) + logging.debug('Shutdown GPIO triggered') + elif self.server.fileno() == fd: + logging.debug('Quitting shutdown manager') diff --git a/modules/slideshow.py b/modules/slideshow.py index 1a6b1ea..ca509f5 100755 --- a/modules/slideshow.py +++ b/modules/slideshow.py @@ -21,325 +21,332 @@ from modules.helper import helper from modules.network import RequestNoNetwork + class slideshow: - SHOWN_IP = False - EVENTS = ["nextImage", "prevImage", "nextAlbum", "prevAlbum", "settingsChange", "memoryForget", "clearCache", "forgetPreload"] - - def __init__(self, display, settings, colormatch, history): - self.countdown = 0 - self.thread = None - self.services = None - self.display = display - self.settings = settings - self.colormatch = colormatch - self.history = history - self.cacheMgr = None - self.void = open(os.devnull, 'wb') - self.delayer = threading.Event() - self.cbStopped = None - - self.eventList = [] - - self.imageCurrent = None - self.skipPreloadedImage = False - - self.historyIndex = -1 - self.minimumWait = 1 - - self.supportedFormats = helper.getSupportedTypes() - - self.running = True - - def setCountdown(self, seconds): - if seconds < 1: - self.countdown = 0 - else: - self.countdown = seconds - - def getCurrentImage(self): - return self.imageCurrent.filename, self.imageCurrent.mimetype - - def getColorInformation(self): - return { - 'temperature':self.colormatch.getTemperature(), - 'lux':self.colormatch.getLux() - } - - def setServiceManager(self, services): - self.services = services - - def setCacheManager(self, cacheMgr): - self.cacheMgr = cacheMgr - - def shouldShow(self, show): - logging.debug('shouldShow called with %d', show) - if show: - logging.debug('Calling start()') - self.start() - else: - logging.debug('Calling stop()') - self.stop() - - def start(self, blank=False): - if blank: - self.display.clear() - - if self.thread is None: - self.thread = threading.Thread(target=self.presentation) - self.thread.daemon = True - self.running = True - self.imageCurrent = None - self.thread.start() - - def stop(self, cbStopped=None): - self.cbStopped = cbStopped - self.running = False - self.imageCurrent = None - self.delayer.set() - - def trigger(self): - logging.debug('Causing immediate showing of image') - self.cleanConfig = True - self.delayer.set() - - def createEvent(self, cmd): - if cmd not in slideshow.EVENTS: - logging.warning("Unknown event '%s' received, will not act upon it" % cmd) - return - else: - logging.debug('Event %s added to the queue', cmd) - - self.eventList.append(cmd) - self.delayer.set() - - def handleEvents(self): - showNext = True - while len(self.eventList) > 0: - event = self.eventList.pop(0) - - if event == 'memoryForget' or event == 'clearCache': - if event == 'memoryForget': - self.services.memoryForget() - if event == 'clearCache': - self.cacheMgr.empty() - if self.imageCurrent: - self.imageCurrent = None - self.display.clear() - showNext = False - elif event == "nextImage": - logging.info('nextImage called, historyIndex is %d', self.historyIndex) - elif event == "prevImage": - if self.historyIndex == -1: - # special case, first time, history holds what we're showing, so step twice - self.historyIndex = min(self.history.getAvailable()-1, self.historyIndex+2) + SHOWN_IP = False + EVENTS = ["nextImage", "prevImage", "nextAlbum", "prevAlbum", + "settingsChange", "memoryForget", "clearCache", "forgetPreload"] + + def __init__(self, display, settings, colormatch, history): + self.countdown = 0 + self.thread = None + self.services = None + self.display = display + self.settings = settings + self.colormatch = colormatch + self.history = history + self.cacheMgr = None + self.void = open(os.devnull, 'wb') + self.delayer = threading.Event() + self.cbStopped = None + + self.eventList = [] + + self.imageCurrent = None + self.skipPreloadedImage = False + + self.historyIndex = -1 + self.minimumWait = 1 + + self.supportedFormats = helper.getSupportedTypes() + + self.running = True + + def setCountdown(self, seconds): + if seconds < 1: + self.countdown = 0 else: - self.historyIndex = min(self.history.getAvailable()-1, self.historyIndex+1) - logging.info('prevImage called, historyIndex is %d', self.historyIndex) - showNext = False - elif event == "nextAlbum": - # FIX - self.skipPreloadedImage = True - self.services.nextAlbum() + self.countdown = seconds + + def getCurrentImage(self): + return self.imageCurrent.filename, self.imageCurrent.mimetype + + def getColorInformation(self): + return { + 'temperature': self.colormatch.getTemperature(), + 'lux': self.colormatch.getLux() + } + + def setServiceManager(self, services): + self.services = services + + def setCacheManager(self, cacheMgr): + self.cacheMgr = cacheMgr + + def shouldShow(self, show): + logging.debug('shouldShow called with %d', show) + if show: + logging.debug('Calling start()') + self.start() + else: + logging.debug('Calling stop()') + self.stop() + + def start(self, blank=False): + if blank: + self.display.clear() + + if self.thread is None: + self.thread = threading.Thread(target=self.presentation) + self.thread.daemon = True + self.running = True + self.imageCurrent = None + self.thread.start() + + def stop(self, cbStopped=None): + self.cbStopped = cbStopped + self.running = False + self.imageCurrent = None self.delayer.set() - elif event == "prevAlbum": - # FIX - self.skipPreloadedImage = True - self.services.prevAlbum() + + def trigger(self): + logging.debug('Causing immediate showing of image') + self.cleanConfig = True self.delayer.set() - elif event == 'forgetPreload': - self.skipPreloadedImage = True - return showNext - - def startupScreen(self): - slideshow.SHOWN_IP = True - # Once we have IP, show for 10s - cd = self.countdown - while (cd > 0): - time_process = time.time() - self.display.message('Starting in %d' % (cd)) - cd -= 1 - time_process = time.time() - time_process - if time_process < 1.0: - time.sleep(1.0 - time_process) - self.display.clear() - - def waitForNetwork(self): - self.imageCurrent = None - helper.waitForNetwork( - lambda: self.display.message('No internet connection\n\nCheck router, wifi-config.txt or cable'), - lambda: self.settings.getUser('offline-behavior') != 'wait' - ) - self.display.setConfigPage('http://%s:%d/' % (helper.getDeviceIp(), 7777)) - - def handleErrors(self, result): - if result is None: - serviceStates = self.services.getAllServiceStates() - if len(serviceStates) == 0: - msg = 'Photoframe isn\'t ready yet\n\nPlease direct your webbrowser to\n\nhttp://%s:7777/\n\nand add one or more photo providers' % helper.getDeviceIp() - else: - msg = 'Please direct your webbrowser to\n\nhttp://%s:7777/\n\nto complete the setup process' % helper.getDeviceIp() - for svcName, state, additionalInfo in serviceStates: - msg += "\n\n"+svcName+": " - if state == 'OAUTH': - msg += "Authorization required" - elif state == 'CONFIG': - msg += "Configuration required" - elif state == 'NEED_KEYWORDS': - msg += "Add one or more keywords (album names)" - elif state == 'NO_IMAGES': - msg += "No images could be found" - - if additionalInfo is not None: - msg += "\n\n"+additionalInfo - - self.display.message(msg) - self.imageCurrent = None - return True - - if result.error is not None: - logging.debug('%s failed:\n\n%s' % (self.services.getLastUsedServiceName(), result.error)) - self.display.message('%s failed:\n\n%s' % (self.services.getLastUsedServiceName(), result.error)) - self.imageCurrent = None - return True - return False - - def _colormatch(self, filenameProcessed): - if self.colormatch.hasSensor(): - # For Now: Always process original image (no caching of colormatch-adjusted images) - # 'colormatched_tmp.jpg' will be deleted after the image is displayed - p, f = os.path.split(filenameProcessed) - ofile = os.path.join(p, "colormatch_" + f + '.png') - if self.colormatch.adjust(filenameProcessed, ofile): - os.unlink(filenameProcessed) - return ofile - logging.warning('Unable to adjust image to colormatch, using original') - return filenameProcessed - - def remember(self, image): - logging.debug('Commit this to history') - self.history.add(image) - - def process(self, image): - logging.debug('Processing %s', image.id) - imageSizing = self.settings.getUser('imagesizing') - - # Make sure it's oriented correctly - filename = helper.autoRotate(image.filename) - - # At this point, we have a good image, store it if allowed - if image.cacheAllow and not image.cacheUsed: - self.cacheMgr.setCachedImage(filename, image.getCacheId()) - - # Frame it - if imageSizing == 'blur': - filename = helper.makeFullframe(filename, self.settings.getUser('width'), self.settings.getUser('height')) - elif imageSizing == 'zoom': - filename = helper.makeFullframe(filename, self.settings.getUser('width'), self.settings.getUser('height'), zoomOnly=True) - elif imageSizing == 'auto': - filename = helper.makeFullframe(filename, self.settings.getUser('width'), self.settings.getUser('height'), autoChoose=True) - - # Color match it - return self._colormatch(filename) - - def delayNextImage(self, time_process): - # Delay before we show the image (but take processing into account) - # This should keep us fairly consistent - delay = self.settings.getUser('interval') - if time_process < delay and self.imageCurrent: - self.delayer.wait(delay - time_process) - elif not self.imageCurrent: - self.delayer.wait(self.minimumWait) # Always wait ONE second to avoid busy waiting) - self.delayer.clear() - if self.imageCurrent: - self.minimumWait = 1 - else: - self.minimumWait = min(self.minimumWait * 2, 16) - - def showPreloadedImage(self, image): - if not os.path.isfile(image.filename): - logging.warning("Trying to show image '%s', but file does not exist!" % image.filename) - self.delayer.set() - return - self.display.image(image.filename) - self.imageCurrent = image - - def presentation(self): - self.services.getServices(readyOnly=True) - - # Make sure we have network - if not helper.hasNetwork() and self.settings.getUser('offline-behavior') == 'wait': - self.waitForNetwork() - - if not slideshow.SHOWN_IP: - self.startupScreen() - - logging.info('Starting presentation') - i = 0 - result = None - lastCfg = self.services.getConfigChange() - while self.running: - i += 1 - time_process = time.time() - - if (i % 10) == 0: - self.cacheMgr.garbageCollect() - - displaySize = {'width': self.settings.getUser('width'), 'height': self.settings.getUser('height'), 'force_orientation': self.settings.getUser('force_orientation')} - randomize = self.settings.getUser('randomize_images') - - try: - if self.historyIndex == -1: - result = self.services.servicePrepareNextItem(self.settings.get('tempfolder'), self.supportedFormats, displaySize, randomize) - self.remember(result) + + def createEvent(self, cmd): + if cmd not in slideshow.EVENTS: + logging.warning("Unknown event '%s' received, will not act upon it" % cmd) + return else: - logging.info('Fetching history image %d of %d', self.historyIndex, self.history.getAvailable()) - result = self.history.getByIndex(self.historyIndex) - self.historyIndex = max(-1, self.historyIndex-1) - except RequestNoNetwork: - offline = self.settings.getUser('offline-behavior') - if offline == 'wait': - self.waitForNetwork() - continue - elif offline == 'ignore': - pass - - if not self.handleErrors(result): - filenameProcessed = self.process(result) - result = result.copy().setFilename(filenameProcessed) - else: - result = None + logging.debug('Event %s added to the queue', cmd) - time_process = time.time() - time_process - logging.debug('Took %f seconds to process, next image is %s', time_process, result.filename if result is not None else "None") - self.delayNextImage(time_process) + self.eventList.append(cmd) + self.delayer.set() - showNextImage = self.handleEvents() + def handleEvents(self): + showNext = True + while len(self.eventList) > 0: + event = self.eventList.pop(0) + + if event == 'memoryForget' or event == 'clearCache': + if event == 'memoryForget': + self.services.memoryForget() + if event == 'clearCache': + self.cacheMgr.empty() + if self.imageCurrent: + self.imageCurrent = None + self.display.clear() + showNext = False + elif event == "nextImage": + logging.info('nextImage called, historyIndex is %d', self.historyIndex) + elif event == "prevImage": + if self.historyIndex == -1: + # special case, first time, history holds what we're showing, so step twice + self.historyIndex = min(self.history.getAvailable()-1, self.historyIndex+2) + else: + self.historyIndex = min(self.history.getAvailable()-1, self.historyIndex+1) + logging.info('prevImage called, historyIndex is %d', self.historyIndex) + showNext = False + elif event == "nextAlbum": + # FIX + self.skipPreloadedImage = True + self.services.nextAlbum() + self.delayer.set() + elif event == "prevAlbum": + # FIX + self.skipPreloadedImage = True + self.services.prevAlbum() + self.delayer.set() + elif event == 'forgetPreload': + self.skipPreloadedImage = True + return showNext + + def startupScreen(self): + slideshow.SHOWN_IP = True + # Once we have IP, show for 10s + cd = self.countdown + while (cd > 0): + time_process = time.time() + self.display.message('Starting in %d' % (cd)) + cd -= 1 + time_process = time.time() - time_process + if time_process < 1.0: + time.sleep(1.0 - time_process) + self.display.clear() + + def waitForNetwork(self): + self.imageCurrent = None + helper.waitForNetwork( + lambda: self.display.message('No internet connection\n\nCheck router, wifi-config.txt or cable'), + lambda: self.settings.getUser('offline-behavior') != 'wait' + ) + self.display.setConfigPage('http://%s:%d/' % (helper.getDeviceIp(), 7777)) + + def handleErrors(self, result): + if result is None: + serviceStates = self.services.getAllServiceStates() + if len(serviceStates) == 0: + msg = 'Photoframe isn\'t ready yet\n\nPlease direct your webbrowser to\n\nhttp://%s:7777/\n\nand add one or more photo providers' % helper.getDeviceIp() + else: + msg = 'Please direct your webbrowser to\n\nhttp://%s:7777/\n\nto complete the setup process' % helper.getDeviceIp() + for svcName, state, additionalInfo in serviceStates: + msg += "\n\n"+svcName+": " + if state == 'OAUTH': + msg += "Authorization required" + elif state == 'CONFIG': + msg += "Configuration required" + elif state == 'NEED_KEYWORDS': + msg += "Add one or more keywords (album names)" + elif state == 'NO_IMAGES': + msg += "No images could be found" + + if additionalInfo is not None: + msg += "\n\n"+additionalInfo + + self.display.message(msg) + self.imageCurrent = None + return True + + if result.error is not None: + logging.debug('%s failed:\n\n%s' % (self.services.getLastUsedServiceName(), result.error)) + self.display.message('%s failed:\n\n%s' % (self.services.getLastUsedServiceName(), result.error)) + self.imageCurrent = None + return True + return False + + def _colormatch(self, filenameProcessed): + if self.colormatch.hasSensor(): + # For Now: Always process original image (no caching of colormatch-adjusted images) + # 'colormatched_tmp.jpg' will be deleted after the image is displayed + p, f = os.path.split(filenameProcessed) + ofile = os.path.join(p, "colormatch_" + f + '.png') + if self.colormatch.adjust(filenameProcessed, ofile): + os.unlink(filenameProcessed) + return ofile + logging.warning('Unable to adjust image to colormatch, using original') + return filenameProcessed + + def remember(self, image): + logging.debug('Commit this to history') + self.history.add(image) + + def process(self, image): + logging.debug('Processing %s', image.id) + imageSizing = self.settings.getUser('imagesizing') + + # Make sure it's oriented correctly + filename = helper.autoRotate(image.filename) + + # At this point, we have a good image, store it if allowed + if image.cacheAllow and not image.cacheUsed: + self.cacheMgr.setCachedImage(filename, image.getCacheId()) + + # Frame it + if imageSizing == 'blur': + filename = helper.makeFullframe(filename, self.settings.getUser('width'), self.settings.getUser('height')) + elif imageSizing == 'zoom': + filename = helper.makeFullframe(filename, self.settings.getUser( + 'width'), self.settings.getUser('height'), zoomOnly=True) + elif imageSizing == 'auto': + filename = helper.makeFullframe(filename, self.settings.getUser( + 'width'), self.settings.getUser('height'), autoChoose=True) + + # Color match it + return self._colormatch(filename) + + def delayNextImage(self, time_process): + # Delay before we show the image (but take processing into account) + # This should keep us fairly consistent + delay = self.settings.getUser('interval') + if time_process < delay and self.imageCurrent: + self.delayer.wait(delay - time_process) + elif not self.imageCurrent: + self.delayer.wait(self.minimumWait) # Always wait ONE second to avoid busy waiting) + self.delayer.clear() + if self.imageCurrent: + self.minimumWait = 1 + else: + self.minimumWait = min(self.minimumWait * 2, 16) - # Handle changes to config to avoid showing an image which is unexpected - if self.services.getConfigChange() != lastCfg: - logging.debug('Services have changed, skip next photo and get fresh one') - self.skipPreloadedImage = True - lastCfg = self.services.getConfigChange() + def showPreloadedImage(self, image): + if not os.path.isfile(image.filename): + logging.warning("Trying to show image '%s', but file does not exist!" % image.filename) + self.delayer.set() + return + self.display.image(image.filename) + self.imageCurrent = image - if self.running and result is not None: - # Skip this section if we were killed while waiting around - if showNextImage and not self.skipPreloadedImage: - self.showPreloadedImage(result) - else: - self.imageCurrent = None - self.skipPreloadedImage = False - logging.debug('Deleting temp file "%s"' % result.filename) - os.unlink(result.filename) - - self.thread = None - logging.info('slideshow has ended') - - # Callback if anyone was listening - if self.cbStopped is not None: - logging.debug('Stop required notification, so call them') - tmp = self.cbStopped - self.cbStopped = None - tmp() - -#TODO: -#- Once in history, STOP PRELOADING THE IMAGE, IT BREAKS THINGS BADLY + def presentation(self): + self.services.getServices(readyOnly=True) + + # Make sure we have network + if not helper.hasNetwork() and self.settings.getUser('offline-behavior') == 'wait': + self.waitForNetwork() + + if not slideshow.SHOWN_IP: + self.startupScreen() + + logging.info('Starting presentation') + i = 0 + result = None + lastCfg = self.services.getConfigChange() + while self.running: + i += 1 + time_process = time.time() + + if (i % 10) == 0: + self.cacheMgr.garbageCollect() + + displaySize = {'width': self.settings.getUser('width'), 'height': self.settings.getUser( + 'height'), 'force_orientation': self.settings.getUser('force_orientation')} + randomize = self.settings.getUser('randomize_images') + + try: + if self.historyIndex == -1: + result = self.services.servicePrepareNextItem(self.settings.get( + 'tempfolder'), self.supportedFormats, displaySize, randomize) + self.remember(result) + else: + logging.info('Fetching history image %d of %d', self.historyIndex, self.history.getAvailable()) + result = self.history.getByIndex(self.historyIndex) + self.historyIndex = max(-1, self.historyIndex-1) + except RequestNoNetwork: + offline = self.settings.getUser('offline-behavior') + if offline == 'wait': + self.waitForNetwork() + continue + elif offline == 'ignore': + pass + + if not self.handleErrors(result): + filenameProcessed = self.process(result) + result = result.copy().setFilename(filenameProcessed) + else: + result = None + + time_process = time.time() - time_process + logging.debug('Took %f seconds to process, next image is %s', time_process, + result.filename if result is not None else "None") + self.delayNextImage(time_process) + + showNextImage = self.handleEvents() + + # Handle changes to config to avoid showing an image which is unexpected + if self.services.getConfigChange() != lastCfg: + logging.debug('Services have changed, skip next photo and get fresh one') + self.skipPreloadedImage = True + lastCfg = self.services.getConfigChange() + + if self.running and result is not None: + # Skip this section if we were killed while waiting around + if showNextImage and not self.skipPreloadedImage: + self.showPreloadedImage(result) + else: + self.imageCurrent = None + self.skipPreloadedImage = False + logging.debug('Deleting temp file "%s"' % result.filename) + os.unlink(result.filename) + + self.thread = None + logging.info('slideshow has ended') + + # Callback if anyone was listening + if self.cbStopped is not None: + logging.debug('Stop required notification, so call them') + tmp = self.cbStopped + self.cbStopped = None + tmp() + +# TODO: +# - Once in history, STOP PRELOADING THE IMAGE, IT BREAKS THINGS BADLY diff --git a/modules/sysconfig.py b/modules/sysconfig.py index e5c65ad..c7ff972 100755 --- a/modules/sysconfig.py +++ b/modules/sysconfig.py @@ -21,187 +21,188 @@ from .path import path import logging + class sysconfig: - @staticmethod - def _getConfigFileState(key): - if os.path.exists(path.CONFIG_TXT): - with open(path.CONFIG_TXT, 'r') as f: - for line in f: - clean = line.strip() - if clean == '': - continue - if clean.startswith('%s=' % key): - _, value = clean.split('=', 1) - return value - return None - - @staticmethod - def _changeConfigFile(key, value): - configline = '%s=%s\n' % (key, value) - found = False - if os.path.exists(path.CONFIG_TXT): - with open(path.CONFIG_TXT, 'r') as ifile: - with open(path.CONFIG_TXT + '.new', 'w') as ofile: - for line in ifile: - clean = line.strip() - if clean.startswith('%s=' % key): - found = True - line = configline - ofile.write(line) - if not found: - ofile.write(configline) - try: - os.rename(path.CONFIG_TXT, path.CONFIG_TXT + '.old') - os.rename(path.CONFIG_TXT + '.new', path.CONFIG_TXT) - # Keep the first version of the config.txt just-in-case - if os.path.exists(path.CONFIG_TXT + '.original'): - os.unlink(path.CONFIG_TXT + '.old') + @staticmethod + def _getConfigFileState(key): + if os.path.exists(path.CONFIG_TXT): + with open(path.CONFIG_TXT, 'r') as f: + for line in f: + clean = line.strip() + if clean == '': + continue + if clean.startswith('%s=' % key): + _, value = clean.split('=', 1) + return value + return None + + @staticmethod + def _changeConfigFile(key, value): + configline = '%s=%s\n' % (key, value) + found = False + if os.path.exists(path.CONFIG_TXT): + with open(path.CONFIG_TXT, 'r') as ifile: + with open(path.CONFIG_TXT + '.new', 'w') as ofile: + for line in ifile: + clean = line.strip() + if clean.startswith('%s=' % key): + found = True + line = configline + ofile.write(line) + if not found: + ofile.write(configline) + try: + os.rename(path.CONFIG_TXT, path.CONFIG_TXT + '.old') + os.rename(path.CONFIG_TXT + '.new', path.CONFIG_TXT) + # Keep the first version of the config.txt just-in-case + if os.path.exists(path.CONFIG_TXT + '.original'): + os.unlink(path.CONFIG_TXT + '.old') + else: + os.rename(path.CONFIG_TXT + '.old', path.CONFIG_TXT + '.original') + return True + except: + logging.exception('Failed to activate new config.txt, you may need to restore the config.txt') + + @staticmethod + def isDisplayRotated(): + state = sysconfig._getConfigFileState('display_rotate') + if state is not None: + return state.endswith('1') or state.endswith('3') + return False + + @staticmethod + def getDisplayOrientation(): + rotate = 0 + state = sysconfig._getConfigFileState('display_rotate') + if state is not None: + rotate = int(state)*90 + return rotate + + @staticmethod + def setDisplayOverscan(enable): + if enable: + return sysconfig._changeConfigFile('disable_overscan', '0') else: - os.rename(path.CONFIG_TXT + '.old', path.CONFIG_TXT + '.original') - return True - except: - logging.exception('Failed to activate new config.txt, you may need to restore the config.txt') - - @staticmethod - def isDisplayRotated(): - state = sysconfig._getConfigFileState('display_rotate') - if state is not None: - return state.endswith('1') or state.endswith('3') - return False - - @staticmethod - def getDisplayOrientation(): - rotate = 0 - state = sysconfig._getConfigFileState('display_rotate') - if state is not None: - rotate = int(state)*90 - return rotate - - @staticmethod - def setDisplayOverscan(enable): - if enable: - return sysconfig._changeConfigFile('disable_overscan', '0') - else: - return sysconfig._changeConfigFile('disable_overscan', '1') - - @staticmethod - def isDisplayOverscan(): - state = sysconfig._getConfigFileState('disable_overscan') - if state is not None: - return state == '0' - return True # Typically true for RPi - - @staticmethod - def setDisplayOrientation(deg): - return sysconfig._changeConfigFile('display_rotate', '%d' % int(deg/90)) - - @staticmethod - def _app_opt_load(): - if os.path.exists(path.OPTIONSFILE): - lines = {} - with open(path.OPTIONSFILE, 'r') as f: - for line in f: - key, value = line.strip().split('=',1) - lines[key.strip()] = value.strip() - return lines - return None - - @staticmethod - def _app_opt_save(lines): - with open(path.OPTIONSFILE, 'w') as f: - for key in lines: - f.write('%s=%s\n' % (key, lines[key])) - - @staticmethod - def setOption(key, value): - lines = sysconfig._app_opt_load() - if lines is None: - lines = {} - lines[key] = value - sysconfig._app_opt_save(lines) - - @staticmethod - def getOption(key): - lines = sysconfig._app_opt_load() - if lines is None: - lines = {} - if key in lines: - return lines[key] - return None - - @staticmethod - def removeOption(key): - lines = sysconfig._app_opt_load() - if lines is None: - return - lines.pop(key, False) - sysconfig._app_opt_save(lines) - - @staticmethod - def getHTTPAuth(): - user = None - userfiles = ['/boot/http-auth.json', path.CONFIGFOLDER + '/http-auth.json'] - for userfile in userfiles: - if os.path.exists(userfile): - logging.debug('Found "%s", loading the data' % userfile) + return sysconfig._changeConfigFile('disable_overscan', '1') + + @staticmethod + def isDisplayOverscan(): + state = sysconfig._getConfigFileState('disable_overscan') + if state is not None: + return state == '0' + return True # Typically true for RPi + + @staticmethod + def setDisplayOrientation(deg): + return sysconfig._changeConfigFile('display_rotate', '%d' % int(deg/90)) + + @staticmethod + def _app_opt_load(): + if os.path.exists(path.OPTIONSFILE): + lines = {} + with open(path.OPTIONSFILE, 'r') as f: + for line in f: + key, value = line.strip().split('=', 1) + lines[key.strip()] = value.strip() + return lines + return None + + @staticmethod + def _app_opt_save(lines): + with open(path.OPTIONSFILE, 'w') as f: + for key in lines: + f.write('%s=%s\n' % (key, lines[key])) + + @staticmethod + def setOption(key, value): + lines = sysconfig._app_opt_load() + if lines is None: + lines = {} + lines[key] = value + sysconfig._app_opt_save(lines) + + @staticmethod + def getOption(key): + lines = sysconfig._app_opt_load() + if lines is None: + lines = {} + if key in lines: + return lines[key] + return None + + @staticmethod + def removeOption(key): + lines = sysconfig._app_opt_load() + if lines is None: + return + lines.pop(key, False) + sysconfig._app_opt_save(lines) + + @staticmethod + def getHTTPAuth(): + user = None + userfiles = ['/boot/http-auth.json', path.CONFIGFOLDER + '/http-auth.json'] + for userfile in userfiles: + if os.path.exists(userfile): + logging.debug('Found "%s", loading the data' % userfile) + try: + with open(userfile, 'rb') as f: + user = json.load(f) + if 'user' not in user or 'password' not in user: + logging.warning("\"%s\" doesn't contain a user and password key" % userfile) + user = None + else: + break + except: + logging.exception('Unable to load JSON from "%s"' % userfile) + user = None + return user + + @staticmethod + def setHostname(name): + # First, make sure it's legal + name = re.sub(' ', '-', name.strip()) + name = re.sub('[^a-zA-Z0-9\-]', '', name).strip() + if not name or len(name) > 63: + return False + + # Next, let's edit the relevant files.... + with open('/etc/hostname', 'w') as f: + f.write('%s\n' % name) + + lines = [] + with open('/etc/hosts', 'r') as f: + for line in f: + line = line.strip() + if line.startswith('127.0.1.1'): + line = '127.0.1.1\t%s' % name + lines.append(line) + with open('/etc/hosts.new', 'w') as f: + for line in lines: + f.write('%s\n' % line) + try: - with open(userfile, 'rb') as f: - user = json.load(f) - if 'user' not in user or 'password' not in user: - logging.warning("\"%s\" doesn't contain a user and password key" % userfile) - user = None - else: - break + os.rename('/etc/hosts', '/etc/hosts.old') + os.rename('/etc/hosts.new', '/etc/hosts') + # Keep the first version of the config.txt just-in-case + os.unlink('/etc/hosts.old') + + # also, run hostname with the new name + with open(os.devnull, 'wb') as void: + subprocess.check_call(['/bin/hostname', name], stderr=void) + + # Final step, restart avahi (so it knows the correct hostname) + try: + with open(os.devnull, 'wb') as void: + subprocess.check_call(['/usr/sbin/service', 'avahi-daemon', 'restart'], stderr=void) + except subprocess.CalledProcessError: + logging.exception('Couldnt restart avahi, not a deal breaker') + return True except: - logging.exception('Unable to load JSON from "%s"' % userfile) - user = None - return user - - @staticmethod - def setHostname(name): - # First, make sure it's legal - name = re.sub(' ', '-', name.strip()); - name = re.sub('[^a-zA-Z0-9\-]', '', name).strip() - if not name or len(name) > 63: - return False - - # Next, let's edit the relevant files.... - with open('/etc/hostname', 'w') as f: - f.write('%s\n' % name) - - lines = [] - with open('/etc/hosts', 'r') as f: - for line in f: - line = line.strip() - if line.startswith('127.0.1.1'): - line = '127.0.1.1\t%s' % name - lines.append(line) - with open('/etc/hosts.new', 'w') as f: - for line in lines: - f.write('%s\n' % line) - - try: - os.rename('/etc/hosts', '/etc/hosts.old') - os.rename('/etc/hosts.new', '/etc/hosts') - # Keep the first version of the config.txt just-in-case - os.unlink('/etc/hosts.old') - - # also, run hostname with the new name - with open(os.devnull, 'wb') as void: - subprocess.check_call(['/bin/hostname', name], stderr=void) - - # Final step, restart avahi (so it knows the correct hostname) - try: - with open(os.devnull, 'wb') as void: - subprocess.check_call(['/usr/sbin/service', 'avahi-daemon', 'restart'], stderr=void) - except subprocess.CalledProcessError: - logging.exception('Couldnt restart avahi, not a deal breaker') - return True - except: - logging.exception('Failed to activate new hostname, you should probably reboot to restore') - return False - - @staticmethod - def getHostname(): - with open('/etc/hostname', 'r') as f: - return f.read().strip() + logging.exception('Failed to activate new hostname, you should probably reboot to restore') + return False + + @staticmethod + def getHostname(): + with open('/etc/hostname', 'r') as f: + return f.read().strip() diff --git a/modules/timekeeper.py b/modules/timekeeper.py index e219b29..71af1d8 100755 --- a/modules/timekeeper.py +++ b/modules/timekeeper.py @@ -18,121 +18,123 @@ import time # Start timer for keeping display on/off + + class timekeeper(Thread): - def __init__(self): - Thread.__init__(self) - self.daemon = True - self.scheduleOff = False - self.ambientOff = False - self.standby = False - self.ignoreSensor = True - self.ignoreSchedule = True - - self.hourOn = None - self.hourOff = None - self.luxLimit = None - self.luxTimeout = None - self.luxLow = None - self.luxHigh = None - self.listeners = [] - self.start() - - def registerListener(self, cbPowerState): - logging.debug('Adding listener %s' % repr(cbPowerState)) - self.listeners.append(cbPowerState) - - def setConfiguration(self, hourOn, hourOff): - self.hourOn = hourOn - self.hourOff = hourOff - logging.debug('hourOn = %s, hourOff = %s' % (repr(hourOn), repr(hourOff))) - - def setPowermode(self, mode): - if mode == '' or mode == 'none': - mode = 'none' - self.ignoreSensor = True - self.ignoreSchedule = True - elif mode == 'sensor': - self.ignoreSensor = False - self.ignoreSchedule = True - elif mode == 'schedule': - self.ignoreSensor = True - self.ignoreSchedule = False - elif mode == 'sensor+schedule': - self.ignoreSensor = False - self.ignoreSchedule = False - logging.debug('Powermode changed to ' + repr(mode)) - self.luxLow = None - self.luxHigh = None - self.ambientOff = False - self.evaluatePower() - - def setAmbientSensitivity(self, luxLimit, timeout): - self.luxLimit = luxLimit - self.luxTimeout = timeout - self.luxLow = None - self.luxHigh = None - self.ambientOff = False - - def getDisplayOn(self): - return not self.standby - - def sensorListener(self, temperature, lux): - if self.luxLimit is None or self.luxTimeout is None: - return - if lux < self.luxLimit and self.luxLow is None: - self.luxLow = time.time() + self.luxTimeout * 60 - self.luxHigh = None - elif lux >= self.luxLimit and self.luxLow is not None: - self.luxLow = None - self.luxHigh = time.time() + self.luxTimeout * 60 - - previously = self.ambientOff - if not self.standby and self.luxLow and time.time() > self.luxLow: - self.ambientOff = True - elif self.standby and self.luxHigh and time.time() > self.luxHigh: - self.ambientOff = False - if previously != self.ambientOff: - logging.debug('Ambient power state has changed: %s', repr(self.ambientOff)) - self.evaluatePower() - - def evaluatePower(self): - # Either source can turn off display but scheduleOff takes priority on power on - # NOTE! Schedule and sensor can be overriden - if not self.standby and ((not self.ignoreSchedule and self.scheduleOff) or (not self.ignoreSensor and self.ambientOff)): - self.standby = True - self.notifyListeners(False) - elif self.standby and (self.ignoreSchedule or not self.scheduleOff) and (self.ignoreSensor or not self.ambientOff): - self.standby = False - self.notifyListeners(True) - - def notifyListeners(self, hasPower): - if len(self.listeners) == 0: - logging.warning('No registered listeners') - for listener in self.listeners: - logging.debug('Notifying %s of power change to %d' % (repr(listener), hasPower)) - listener(hasPower) - - def run(self): - self.scheduleOff = False - while True: - time.sleep(60) # every minute - if self.hourOn is not None and self.hourOff is not None: - if self.hourOn > self.hourOff: - stateBegin = self.hourOff - stateEnd = self.hourOn - stateMode = True - else: - stateBegin = self.hourOn - stateEnd = self.hourOff - stateMode = False - - previouslyOff = self.scheduleOff - hour = int(time.strftime('%H')) - if hour >= stateBegin and hour < stateEnd: - self.scheduleOff = stateMode - else: - self.scheduleOff = not stateMode - - if self.scheduleOff != previouslyOff: - logging.debug('Schedule has triggered change in power, standby is now %s' % repr(self.scheduleOff)) - self.evaluatePower() + def __init__(self): + Thread.__init__(self) + self.daemon = True + self.scheduleOff = False + self.ambientOff = False + self.standby = False + self.ignoreSensor = True + self.ignoreSchedule = True + + self.hourOn = None + self.hourOff = None + self.luxLimit = None + self.luxTimeout = None + self.luxLow = None + self.luxHigh = None + self.listeners = [] + self.start() + + def registerListener(self, cbPowerState): + logging.debug('Adding listener %s' % repr(cbPowerState)) + self.listeners.append(cbPowerState) + + def setConfiguration(self, hourOn, hourOff): + self.hourOn = hourOn + self.hourOff = hourOff + logging.debug('hourOn = %s, hourOff = %s' % (repr(hourOn), repr(hourOff))) + + def setPowermode(self, mode): + if mode == '' or mode == 'none': + mode = 'none' + self.ignoreSensor = True + self.ignoreSchedule = True + elif mode == 'sensor': + self.ignoreSensor = False + self.ignoreSchedule = True + elif mode == 'schedule': + self.ignoreSensor = True + self.ignoreSchedule = False + elif mode == 'sensor+schedule': + self.ignoreSensor = False + self.ignoreSchedule = False + logging.debug('Powermode changed to ' + repr(mode)) + self.luxLow = None + self.luxHigh = None + self.ambientOff = False + self.evaluatePower() + + def setAmbientSensitivity(self, luxLimit, timeout): + self.luxLimit = luxLimit + self.luxTimeout = timeout + self.luxLow = None + self.luxHigh = None + self.ambientOff = False + + def getDisplayOn(self): + return not self.standby + + def sensorListener(self, temperature, lux): + if self.luxLimit is None or self.luxTimeout is None: + return + if lux < self.luxLimit and self.luxLow is None: + self.luxLow = time.time() + self.luxTimeout * 60 + self.luxHigh = None + elif lux >= self.luxLimit and self.luxLow is not None: + self.luxLow = None + self.luxHigh = time.time() + self.luxTimeout * 60 + + previously = self.ambientOff + if not self.standby and self.luxLow and time.time() > self.luxLow: + self.ambientOff = True + elif self.standby and self.luxHigh and time.time() > self.luxHigh: + self.ambientOff = False + if previously != self.ambientOff: + logging.debug('Ambient power state has changed: %s', repr(self.ambientOff)) + self.evaluatePower() + + def evaluatePower(self): + # Either source can turn off display but scheduleOff takes priority on power on + # NOTE! Schedule and sensor can be overriden + if not self.standby and ((not self.ignoreSchedule and self.scheduleOff) or (not self.ignoreSensor and self.ambientOff)): + self.standby = True + self.notifyListeners(False) + elif self.standby and (self.ignoreSchedule or not self.scheduleOff) and (self.ignoreSensor or not self.ambientOff): + self.standby = False + self.notifyListeners(True) + + def notifyListeners(self, hasPower): + if len(self.listeners) == 0: + logging.warning('No registered listeners') + for listener in self.listeners: + logging.debug('Notifying %s of power change to %d' % (repr(listener), hasPower)) + listener(hasPower) + + def run(self): + self.scheduleOff = False + while True: + time.sleep(60) # every minute + if self.hourOn is not None and self.hourOff is not None: + if self.hourOn > self.hourOff: + stateBegin = self.hourOff + stateEnd = self.hourOn + stateMode = True + else: + stateBegin = self.hourOn + stateEnd = self.hourOff + stateMode = False + + previouslyOff = self.scheduleOff + hour = int(time.strftime('%H')) + if hour >= stateBegin and hour < stateEnd: + self.scheduleOff = stateMode + else: + self.scheduleOff = not stateMode + + if self.scheduleOff != previouslyOff: + logging.debug('Schedule has triggered change in power, standby is now %s' % repr(self.scheduleOff)) + self.evaluatePower() diff --git a/routes/baseroute.py b/routes/baseroute.py index e41bb46..76baa71 100644 --- a/routes/baseroute.py +++ b/routes/baseroute.py @@ -16,64 +16,65 @@ import logging import flask + class BaseRoute: - SIMPLE = False + SIMPLE = False - class Mapping: - def __init__(self, url): - self._URL = url.strip() - self._METHODS = ['GET'] - self._DEFAULTS = {} + class Mapping: + def __init__(self, url): + self._URL = url.strip() + self._METHODS = ['GET'] + self._DEFAULTS = {} - def addMethod(self, method): - self._METHODS.append(method.upper().strip()) - return self + def addMethod(self, method): + self._METHODS.append(method.upper().strip()) + return self - def clearMethods(self): - self._METHODS = [] - return self + def clearMethods(self): + self._METHODS = [] + return self - def addDefault(self, key, value): - self._DEFAULTS[key] = value - return self + def addDefault(self, key, value): + self._DEFAULTS[key] = value + return self - def clearDefaults(self): - self._DEFAULTS = {} - return self + def clearDefaults(self): + self._DEFAULTS = {} + return self - def __init__(self): - self._MAPPINGS = [] - self.app = None - self.setup() + def __init__(self): + self._MAPPINGS = [] + self.app = None + self.setup() - def _assignServer(self, server): - self.server = server - self.app = server.app + def _assignServer(self, server): + self.server = server + self.app = server.app - def addUrl(self, url): - mapping = self.Mapping(url) - self._MAPPINGS.append(mapping) - return mapping + def addUrl(self, url): + mapping = self.Mapping(url) + self._MAPPINGS.append(mapping) + return mapping - def setup(self): - pass + def setup(self): + pass - def __call__(self, **kwargs): - return self.handle(self.app, **kwargs) + def __call__(self, **kwargs): + return self.handle(self.app, **kwargs) - def handle(self, app, **kwargs): - msg = '%s does not have an implementation' % self._URL - logging.error(msg) - return msg, 200 + def handle(self, app, **kwargs): + msg = '%s does not have an implementation' % self._URL + logging.error(msg) + return msg, 200 - def getRequest(self): - return flask.request + def getRequest(self): + return flask.request - def setAbort(self, code): - return flask.abort(code) + def setAbort(self, code): + return flask.abort(code) - def redirect(self, url): - return flask.redirect(url) + def redirect(self, url): + return flask.redirect(url) - def jsonify(self, data): - return flask.json.jsonify(data) + def jsonify(self, data): + return flask.json.jsonify(data) diff --git a/routes/control.py b/routes/control.py index 906b8d9..87f9a2a 100644 --- a/routes/control.py +++ b/routes/control.py @@ -16,13 +16,13 @@ from .baseroute import BaseRoute -class RouteControl(BaseRoute): - def setupex(self, slideshow): - self.slideshow = slideshow - self.addUrl('/control/') +class RouteControl(BaseRoute): + def setupex(self, slideshow): + self.slideshow = slideshow - def handle(self, app, cmd): - self.slideshow.createEvent(cmd) - return self.jsonify({'control': True}) + self.addUrl('/control/') + def handle(self, app, cmd): + self.slideshow.createEvent(cmd) + return self.jsonify({'control': True}) diff --git a/routes/debug.py b/routes/debug.py index 93fbc98..9abf59e 100644 --- a/routes/debug.py +++ b/routes/debug.py @@ -17,34 +17,36 @@ import modules.debug as debug from .baseroute import BaseRoute + class RouteDebug(BaseRoute): - SIMPLE = True # We have no dependencies to the rest of the system + SIMPLE = True # We have no dependencies to the rest of the system - def setup(self): - self.addUrl('/debug') + def setup(self): + self.addUrl('/debug') - def handle(self, app, **kwargs): - # Special URL, we simply try to extract latest 100 lines from syslog - # and filter out frame messages. These are shown so the user can - # add these to issues. - report = [] - report.append(debug.version()) - report.append(debug.logfile(False)) - report.append(debug.logfile(True)) - report.append(debug.stacktrace()) + def handle(self, app, **kwargs): + # Special URL, we simply try to extract latest 100 lines from syslog + # and filter out frame messages. These are shown so the user can + # add these to issues. + report = [] + report.append(debug.version()) + report.append(debug.logfile(False)) + report.append(debug.logfile(True)) + report.append(debug.stacktrace()) - message = 'Photoframe Log Report' - message = '''

Photoframe Log report

This page is intended to be used when you run into issues which cannot be resolved by the messages displayed on the frame. Please save and attach this information + message = 'Photoframe Log Report' + message = '''

Photoframe Log report

''' - for item in report: - message += '

%s

' % item[0]
-      if item[1]:
-        for line in item[1]:
-          message += line + '\n'
-      else:
-        message += '--- Data unavailable ---'
-      message += '''
''' - if item[2] is not None: - message += item[2] - message += '' - return message, 200 + for item in report: + message += '

%s

' % item[
+                0]
+            if item[1]:
+                for line in item[1]:
+                    message += line + '\n'
+            else:
+                message += '--- Data unavailable ---'
+            message += '''
''' + if item[2] is not None: + message += item[2] + message += '' + return message, 200 diff --git a/routes/details.py b/routes/details.py index 389c173..551bb0d 100755 --- a/routes/details.py +++ b/routes/details.py @@ -21,81 +21,83 @@ from .baseroute import BaseRoute + class RouteDetails(BaseRoute): - def setupex(self, displaymgr, drivermgr, colormatch, slideshow, servicemgr, settings): - self.displaymgr = displaymgr - self.drivermgr = drivermgr - self.colormatch = colormatch - self.slideshow = slideshow - self.servicemgr = servicemgr - self.settings = settings + def setupex(self, displaymgr, drivermgr, colormatch, slideshow, servicemgr, settings): + self.displaymgr = displaymgr + self.drivermgr = drivermgr + self.colormatch = colormatch + self.slideshow = slideshow + self.servicemgr = servicemgr + self.settings = settings - self.void = open(os.devnull, 'wb') + self.void = open(os.devnull, 'wb') - self.addUrl('/details/') + self.addUrl('/details/') - def handle(self, app, about): - if about == 'tvservice': - result = {} - result['resolution'] = self.displaymgr.available() - result['status'] = self.displaymgr.current() - return self.jsonify(result) - elif about == 'current': - image, mime = self.displaymgr.get() - response = app.make_response(image) - response.headers.set('Content-Type', mime) - return response - elif about == 'drivers': - result = list(self.drivermgr.list().keys()) - return self.jsonify(result) - elif about == 'timezone': - result = helper.timezoneList() - return self.jsonify(result) - elif about == 'version': - output = subprocess.check_output(['git', 'log', '-n1'], stderr=self.void) - lines = output.split('\n') - infoDate = lines[2][5:].strip() - infoCommit = lines[0][7:].strip() - output = subprocess.check_output(['git', 'status'], stderr=self.void) - lines = output.split('\n') - infoBranch = lines[0][10:].strip() - return self.jsonify({'date':infoDate, 'commit':infoCommit, 'branch': infoBranch}) - elif about == 'color': - return self.jsonify(self.slideshow.getColorInformation()) - elif about == 'sensor': - return self.jsonify({'sensor' : self.colormatch.hasSensor()}) - elif about == 'display': - return self.jsonify({'display' : self.displaymgr.isEnabled()}) - elif about == 'network': - return self.jsonify({'network' : helper.hasNetwork()}) - elif about == 'hardware': - output = '' - try: - output = subprocess.check_output(['/opt/vc/bin/vcgencmd', 'get_throttled'], stderr=self.void) - except: - logging.exception('Unable to execute /opt/vc/bin/vcgencmd') - if not output.startswith('throttled='): - logging.error('Output from vcgencmd get_throttled has changed') - output = 'throttled=0x0' - try: - h = int(output[10:].strip(), 16) - except: - logging.exception('Unable to convert output from vcgencmd get_throttled') - result = { - 'undervoltage' : h & (1 << 0 | 1 << 16) > 0, - 'frequency': h & (1 << 1 | 1 << 17) > 0, - 'throttling' : h & (1 << 2 | 1 << 18) > 0, - 'temperature' : h & (1 << 3 | 1 << 19) > 0 - } - return self.jsonify(result) - elif about == 'messages': - # This should be made more general purpose since other parts need similar service - msgs = [] - images = self.servicemgr.getTotalImageCount - timeneeded = images * self.settings.getUser('interval') - timeavailable = self.settings.getUser('refresh') * 3600 - if timeavailable > 0 and timeneeded > timeavailable: - msgs.append({'level':'WARNING', 'message' : 'Change every %d seconds with %d images will take %dh, refresh keywords is %dh' % (self.settings.getUser('interval'), images, timeneeded/3600, timeavailable/3600), 'link' : None}) + def handle(self, app, about): + if about == 'tvservice': + result = {} + result['resolution'] = self.displaymgr.available() + result['status'] = self.displaymgr.current() + return self.jsonify(result) + elif about == 'current': + image, mime = self.displaymgr.get() + response = app.make_response(image) + response.headers.set('Content-Type', mime) + return response + elif about == 'drivers': + result = list(self.drivermgr.list().keys()) + return self.jsonify(result) + elif about == 'timezone': + result = helper.timezoneList() + return self.jsonify(result) + elif about == 'version': + output = subprocess.check_output(['git', 'log', '-n1'], stderr=self.void) + lines = output.split('\n') + infoDate = lines[2][5:].strip() + infoCommit = lines[0][7:].strip() + output = subprocess.check_output(['git', 'status'], stderr=self.void) + lines = output.split('\n') + infoBranch = lines[0][10:].strip() + return self.jsonify({'date': infoDate, 'commit': infoCommit, 'branch': infoBranch}) + elif about == 'color': + return self.jsonify(self.slideshow.getColorInformation()) + elif about == 'sensor': + return self.jsonify({'sensor': self.colormatch.hasSensor()}) + elif about == 'display': + return self.jsonify({'display': self.displaymgr.isEnabled()}) + elif about == 'network': + return self.jsonify({'network': helper.hasNetwork()}) + elif about == 'hardware': + output = '' + try: + output = subprocess.check_output(['/opt/vc/bin/vcgencmd', 'get_throttled'], stderr=self.void) + except: + logging.exception('Unable to execute /opt/vc/bin/vcgencmd') + if not output.startswith('throttled='): + logging.error('Output from vcgencmd get_throttled has changed') + output = 'throttled=0x0' + try: + h = int(output[10:].strip(), 16) + except: + logging.exception('Unable to convert output from vcgencmd get_throttled') + result = { + 'undervoltage': h & (1 << 0 | 1 << 16) > 0, + 'frequency': h & (1 << 1 | 1 << 17) > 0, + 'throttling': h & (1 << 2 | 1 << 18) > 0, + 'temperature': h & (1 << 3 | 1 << 19) > 0 + } + return self.jsonify(result) + elif about == 'messages': + # This should be made more general purpose since other parts need similar service + msgs = [] + images = self.servicemgr.getTotalImageCount + timeneeded = images * self.settings.getUser('interval') + timeavailable = self.settings.getUser('refresh') * 3600 + if timeavailable > 0 and timeneeded > timeavailable: + msgs.append({'level': 'WARNING', 'message': 'Change every %d seconds with %d images will take %dh, refresh keywords is %dh' % ( + self.settings.getUser('interval'), images, timeneeded/3600, timeavailable/3600), 'link': None}) - return self.jsonify(msgs) - self.setAbort(404) + return self.jsonify(msgs) + self.setAbort(404) diff --git a/routes/events.py b/routes/events.py index a6aca1f..668766c 100755 --- a/routes/events.py +++ b/routes/events.py @@ -16,19 +16,20 @@ from .baseroute import BaseRoute + class RouteEvents(BaseRoute): - def setupex(self, events): - self.events = events + def setupex(self, events): + self.events = events - self.addUrl('/events').addDefault('since', None).addDefault('id', None) - self.addUrl('/events/').addDefault('id', None) - self.addUrl('/events/remove/').addDefault('since', None) + self.addUrl('/events').addDefault('since', None).addDefault('id', None) + self.addUrl('/events/').addDefault('id', None) + self.addUrl('/events/remove/').addDefault('since', None) - def handle(self, app, since, id): - if since is not None: - return self.jsonify(self.events.getSince(since)) - elif id is not None: - self.events.remove(id) - return 'ok' - else: - return self.jsonify(self.events.getAll()) + def handle(self, app, since, id): + if since is not None: + return self.jsonify(self.events.getSince(since)) + elif id is not None: + self.events.remove(id) + return 'ok' + else: + return self.jsonify(self.events.getAll()) diff --git a/routes/keywords.py b/routes/keywords.py index 0300560..ce45ed2 100755 --- a/routes/keywords.py +++ b/routes/keywords.py @@ -16,45 +16,46 @@ from .baseroute import BaseRoute + class RouteKeywords(BaseRoute): - def setupex(self, servicemgr, slideshow): - self.servicemgr = servicemgr - self.slideshow = slideshow + def setupex(self, servicemgr, slideshow): + self.servicemgr = servicemgr + self.slideshow = slideshow - self.addUrl('/keywords//help') - self.addUrl('/keywords/') - self.addUrl('/keywords//add').clearMethods().addMethod('POST') - self.addUrl('/keywords//delete').clearMethods().addMethod('POST') - self.addUrl('/keywords//source/') - self.addUrl('/keywords//details/') + self.addUrl('/keywords//help') + self.addUrl('/keywords/') + self.addUrl('/keywords//add').clearMethods().addMethod('POST') + self.addUrl('/keywords//delete').clearMethods().addMethod('POST') + self.addUrl('/keywords//source/') + self.addUrl('/keywords//details/') - def handle(self, app, service, index=None): - if self.getRequest().method == 'GET': - if 'source' in self.getRequest().url: - return self.redirect(self.servicemgr.sourceServiceKeywords(service, index)) - elif 'details' in self.getRequest().url: - return self.jsonify(self.servicemgr.detailsServiceKeywords(service, index)) - elif 'help' in self.getRequest().url: - return self.jsonify({'message' : self.servicemgr.helpServiceKeywords(service)}) - else: - return self.jsonify({'keywords' : self.servicemgr.getServiceKeywords(service)}) - elif self.getRequest().method == 'POST' and self.getRequest().json is not None: - result = True - if 'id' not in self.getRequest().json: - hadKeywords = self.servicemgr.hasKeywords() - result = self.servicemgr.addServiceKeywords(service, self.getRequest().json['keywords']) - if result['error'] is not None: - result['status'] = False - else: - result['status'] = True - if hadKeywords != self.servicemgr.hasKeywords(): - # Make slideshow show the change immediately, we have keywords - self.slideshow.trigger() - else: - if not self.servicemgr.removeServiceKeywords(service, self.getRequest().json['id']): - result = {'status':False, 'error' : 'Unable to remove keyword'} - else: - # Trigger slideshow, we have removed some keywords - self.slideshow.trigger() - return self.jsonify(result) - self.setAbort(500) + def handle(self, app, service, index=None): + if self.getRequest().method == 'GET': + if 'source' in self.getRequest().url: + return self.redirect(self.servicemgr.sourceServiceKeywords(service, index)) + elif 'details' in self.getRequest().url: + return self.jsonify(self.servicemgr.detailsServiceKeywords(service, index)) + elif 'help' in self.getRequest().url: + return self.jsonify({'message': self.servicemgr.helpServiceKeywords(service)}) + else: + return self.jsonify({'keywords': self.servicemgr.getServiceKeywords(service)}) + elif self.getRequest().method == 'POST' and self.getRequest().json is not None: + result = True + if 'id' not in self.getRequest().json: + hadKeywords = self.servicemgr.hasKeywords() + result = self.servicemgr.addServiceKeywords(service, self.getRequest().json['keywords']) + if result['error'] is not None: + result['status'] = False + else: + result['status'] = True + if hadKeywords != self.servicemgr.hasKeywords(): + # Make slideshow show the change immediately, we have keywords + self.slideshow.trigger() + else: + if not self.servicemgr.removeServiceKeywords(service, self.getRequest().json['id']): + result = {'status': False, 'error': 'Unable to remove keyword'} + else: + # Trigger slideshow, we have removed some keywords + self.slideshow.trigger() + return self.jsonify(result) + self.setAbort(500) diff --git a/routes/maintenance.py b/routes/maintenance.py index 762f638..09c527e 100755 --- a/routes/maintenance.py +++ b/routes/maintenance.py @@ -20,66 +20,67 @@ from .baseroute import BaseRoute from modules.path import path + class RouteMaintenance(BaseRoute): - def setupex(self, emulator, drivermgr, slideshow): - self.drivermgr = drivermgr - self.emulator = emulator - self.slideshow = slideshow - self.void = open(os.devnull, 'wb') + def setupex(self, emulator, drivermgr, slideshow): + self.drivermgr = drivermgr + self.emulator = emulator + self.slideshow = slideshow + self.void = open(os.devnull, 'wb') - self.addUrl('/maintenance/') + self.addUrl('/maintenance/') - def handle(self, app, cmd): - if cmd == 'reset': - # Remove driver if active - self.drivermgr.activate(None) - # Delete configuration data - if os.path.exists(path.CONFIGFOLDER): - shutil.rmtree(path.CONFIGFOLDER, True) - # Reboot - if not self.emulator: - subprocess.call(['/sbin/reboot'], stderr=self.void); - else: - self.server.stop() - return self.jsonify({'reset': True}) - elif cmd == 'reboot': - if not self.emulator: - subprocess.call(['/sbin/reboot'], stderr=self.void); - else: - self.server.stop() - return self.jsonify({'reboot' : True}) - elif cmd == 'shutdown': - if not self.emulator: - subprocess.call(['/sbin/poweroff'], stderr=self.void); - else: - self.server.stop() - return self.jsonify({'shutdown': True}) - elif cmd == 'checkversion': - if os.path.exists('update.sh'): - with open(os.devnull, 'wb') as void: - result = subprocess.call(['/bin/bash', 'update.sh', 'checkversion'], stderr=void) - if result == 0: - return self.jsonify({'checkversion' : False}) - elif result == 1: - return self.jsonify({'checkversion' : True}) - else: - return self.jsonify({'checkversion' : False, 'error' : True}) - else: - return 'Cannot find update tool', 404 - elif cmd == 'update': - if self.emulator: - return 'Cannot run update from emulation mode', 200 - if os.path.exists('update.sh'): - subprocess.Popen('/bin/bash update.sh 2>&1 | logger -t forced_update', shell=True) - return 'Update in process', 200 - else: - return 'Cannot find update tool', 404 - elif cmd == 'clearCache': - self.slideshow.createEvent("clearCache") - return self.jsonify({'clearCache': True}) - elif cmd == 'forgetMemory': - self.slideshow.createEvent("memoryForget") - return self.jsonify({'forgetMemory': True}) - elif cmd == 'ssh': - subprocess.call(['systemctl', 'restart', 'ssh'], stderr=self.void) - return self.jsonify({'ssh': True}) + def handle(self, app, cmd): + if cmd == 'reset': + # Remove driver if active + self.drivermgr.activate(None) + # Delete configuration data + if os.path.exists(path.CONFIGFOLDER): + shutil.rmtree(path.CONFIGFOLDER, True) + # Reboot + if not self.emulator: + subprocess.call(['/sbin/reboot'], stderr=self.void) + else: + self.server.stop() + return self.jsonify({'reset': True}) + elif cmd == 'reboot': + if not self.emulator: + subprocess.call(['/sbin/reboot'], stderr=self.void) + else: + self.server.stop() + return self.jsonify({'reboot': True}) + elif cmd == 'shutdown': + if not self.emulator: + subprocess.call(['/sbin/poweroff'], stderr=self.void) + else: + self.server.stop() + return self.jsonify({'shutdown': True}) + elif cmd == 'checkversion': + if os.path.exists('update.sh'): + with open(os.devnull, 'wb') as void: + result = subprocess.call(['/bin/bash', 'update.sh', 'checkversion'], stderr=void) + if result == 0: + return self.jsonify({'checkversion': False}) + elif result == 1: + return self.jsonify({'checkversion': True}) + else: + return self.jsonify({'checkversion': False, 'error': True}) + else: + return 'Cannot find update tool', 404 + elif cmd == 'update': + if self.emulator: + return 'Cannot run update from emulation mode', 200 + if os.path.exists('update.sh'): + subprocess.Popen('/bin/bash update.sh 2>&1 | logger -t forced_update', shell=True) + return 'Update in process', 200 + else: + return 'Cannot find update tool', 404 + elif cmd == 'clearCache': + self.slideshow.createEvent("clearCache") + return self.jsonify({'clearCache': True}) + elif cmd == 'forgetMemory': + self.slideshow.createEvent("memoryForget") + return self.jsonify({'forgetMemory': True}) + elif cmd == 'ssh': + subprocess.call(['systemctl', 'restart', 'ssh'], stderr=self.void) + return self.jsonify({'ssh': True}) diff --git a/routes/oauthlink.py b/routes/oauthlink.py index 4fe5b10..4b04d10 100644 --- a/routes/oauthlink.py +++ b/routes/oauthlink.py @@ -19,6 +19,7 @@ from .baseroute import BaseRoute + class RouteOAuthLink(BaseRoute): def setupex(self, servicemgr, slideshow): self.servicemgr = servicemgr @@ -29,35 +30,34 @@ def setupex(self, servicemgr, slideshow): self.addUrl('/service//oauth').clearMethods().addMethod('POST') def handle(self, app, **kwargs): - print((self.getRequest().url)) - if '/callback?' in self.getRequest().url: - # Figure out who should get this result... - old = self.servicemgr.hasReadyServices() - if self.servicemgr.oauthCallback(self.getRequest()): - # Request handled - if old != self.servicemgr.hasReadyServices(): - self.slideshow.trigger() - return self.redirect('/') - else: + print((self.getRequest().url)) + if '/callback?' in self.getRequest().url: + # Figure out who should get this result... + old = self.servicemgr.hasReadyServices() + if self.servicemgr.oauthCallback(self.getRequest()): + # Request handled + if old != self.servicemgr.hasReadyServices(): + self.slideshow.trigger() + return self.redirect('/') + else: + self.setAbort(500) + elif self.getRequest().url.endswith('/link'): + return self.redirect(self.servicemgr.oauthStart(kwargs['service'])) + elif self.getRequest().url.endswith('/oauth'): + # This one is special, this is a file upload of the JSON config data + # and since we don't need a physical file for it, we should just load + # the data. For now... ignore + if 'filename' not in self.getRequest().files: + logging.error('No file part') + return self.setAbort(405) + file = self.getRequest().files['filename'] + data = json.load(file) + if 'web' in data: + data = data['web'] + if 'redirect_uris' in data and 'https://photoframe.sensenet.nu' not in data['redirect_uris']: + return 'The redirect uri is not set to https://photoframe.sensenet.nu', 405 + if not self.servicemgr.oauthConfig(kwargs['service'], data): + return 'Configuration was invalid', 405 + return 'Configuration set', 200 + else: self.setAbort(500) - elif self.getRequest().url.endswith('/link'): - return self.redirect(self.servicemgr.oauthStart(kwargs['service'])) - elif self.getRequest().url.endswith('/oauth'): - #j = self.getRequest().json - # This one is special, this is a file upload of the JSON config data - # and since we don't need a physical file for it, we should just load - # the data. For now... ignore - if 'filename' not in self.getRequest().files: - logging.error('No file part') - return self.setAbort(405) - file = self.getRequest().files['filename'] - data = json.load(file) - if 'web' in data: - data = data['web'] - if 'redirect_uris' in data and 'https://photoframe.sensenet.nu' not in data['redirect_uris']: - return 'The redirect uri is not set to https://photoframe.sensenet.nu', 405 - if not self.servicemgr.oauthConfig(kwargs['service'], data): - return 'Configuration was invalid', 405 - return 'Configuration set', 200 - else: - self.setAbort(500) diff --git a/routes/options.py b/routes/options.py index cb8bed4..00bd2c5 100644 --- a/routes/options.py +++ b/routes/options.py @@ -17,22 +17,24 @@ from modules.sysconfig import sysconfig from .baseroute import BaseRoute -#@app.route('/options/') +# @app.route('/options/') + + class RouteOptions(BaseRoute): - SIMPLE = True + SIMPLE = True - def setup(self): - self.addUrl('/options/') - self.addUrl('/options//') + def setup(self): + self.addUrl('/options/') + self.addUrl('/options//') - def handle(self, app, cmd, arg=None): - cmd = cmd.upper() - if cmd == 'DEBUG': - if arg is not None: - sysconfig.setOption('POSTCMD', '"--debug"' if arg == 'true' else '') - return self.jsonify({'debug' : '--debug' in sysconfig.getOption('POSTCMD')}) - elif cmd == 'HOSTNAME': - if arg is not None: - sysconfig.setHostname(arg.strip()) - return self.jsonify({'hostname' : sysconfig.getHostname()}) - self.setAbort(404) + def handle(self, app, cmd, arg=None): + cmd = cmd.upper() + if cmd == 'DEBUG': + if arg is not None: + sysconfig.setOption('POSTCMD', '"--debug"' if arg == 'true' else '') + return self.jsonify({'debug': '--debug' in sysconfig.getOption('POSTCMD')}) + elif cmd == 'HOSTNAME': + if arg is not None: + sysconfig.setHostname(arg.strip()) + return self.jsonify({'hostname': sysconfig.getHostname()}) + self.setAbort(404) diff --git a/routes/orientation.py b/routes/orientation.py index f6559a6..46ff1a7 100644 --- a/routes/orientation.py +++ b/routes/orientation.py @@ -17,20 +17,22 @@ from modules.sysconfig import sysconfig from .baseroute import BaseRoute -#@auth.login_required +# @auth.login_required + + class RouteOrientation(BaseRoute): - def setupex(self, cachemgr): - self.cachemgr = cachemgr + def setupex(self, cachemgr): + self.cachemgr = cachemgr - self.addUrl('/rotation').addDefault('orient', None) - self.addUrl('/rotation/').clearMethods().addMethod('PUT') + self.addUrl('/rotation').addDefault('orient', None) + self.addUrl('/rotation/').clearMethods().addMethod('PUT') - def handle(self, app, orient): - if orient is None: - return self.jsonify({'rotation' : sysconfig.getDisplayOrientation()}) - else: - if orient >= 0 and orient < 360: - sysconfig.setDisplayOrientation(orient) - self.cachemgr.empty() - return self.jsonify({'rotation' : sysconfig.getDisplayOrientation()}) - self.setAbort(500) + def handle(self, app, orient): + if orient is None: + return self.jsonify({'rotation': sysconfig.getDisplayOrientation()}) + else: + if orient >= 0 and orient < 360: + sysconfig.setDisplayOrientation(orient) + self.cachemgr.empty() + return self.jsonify({'rotation': sysconfig.getDisplayOrientation()}) + self.setAbort(500) diff --git a/routes/overscan.py b/routes/overscan.py index 5b9da82..d785d31 100644 --- a/routes/overscan.py +++ b/routes/overscan.py @@ -17,18 +17,20 @@ from modules.sysconfig import sysconfig from .baseroute import BaseRoute -#@auth.login_required +# @auth.login_required + + class RouteOverscan(BaseRoute): - def setupex(self, cachemgr): - self.cachemgr = cachemgr + def setupex(self, cachemgr): + self.cachemgr = cachemgr - self.addUrl('/overscan').addDefault('overscan', None) - self.addUrl('/overscan/').clearMethods().addMethod('PUT') + self.addUrl('/overscan').addDefault('overscan', None) + self.addUrl('/overscan/').clearMethods().addMethod('PUT') - def handle(self, app, overscan): - if overscan is None: - return self.jsonify({'overscan' : sysconfig.isDisplayOverscan()}) - else: - sysconfig.setDisplayOverscan(overscan == 'true') - return self.jsonify({'overscan' : sysconfig.isDisplayOverscan()}) - self.setAbort(500) + def handle(self, app, overscan): + if overscan is None: + return self.jsonify({'overscan': sysconfig.isDisplayOverscan()}) + else: + sysconfig.setDisplayOverscan(overscan == 'true') + return self.jsonify({'overscan': sysconfig.isDisplayOverscan()}) + self.setAbort(500) diff --git a/routes/pages.py b/routes/pages.py index 0142fec..152a369 100644 --- a/routes/pages.py +++ b/routes/pages.py @@ -19,6 +19,7 @@ from .baseroute import BaseRoute + class RoutePages(BaseRoute): SIMPLE = True @@ -27,11 +28,11 @@ def setup(self): self.addUrl('/').addDefault('file', None) def handle(self, app, **kwargs): - file = kwargs['file'] - if file is None: - file = 'index.html' + file = kwargs['file'] + if file is None: + file = 'index.html' - root_dir = os.path.join(os.getcwd(), 'static') - if '..' in file: - return 'File not found', 404 - return send_from_directory(root_dir, file) + root_dir = os.path.join(os.getcwd(), 'static') + if '..' in file: + return 'File not found', 404 + return send_from_directory(root_dir, file) diff --git a/routes/service.py b/routes/service.py index 86d6992..c5eaecf 100644 --- a/routes/service.py +++ b/routes/service.py @@ -16,44 +16,45 @@ from .baseroute import BaseRoute -#@app.route('/service/', methods=['GET', 'POST']) -#@auth.login_required -class RouteService(BaseRoute): - def setupex(self, servicemgr, slideshow): - self.servicemgr = servicemgr - self.slideshow = slideshow +# @app.route('/service/', methods=['GET', 'POST']) +# @auth.login_required + - self.addUrl('/service/').addMethod('POST') +class RouteService(BaseRoute): + def setupex(self, servicemgr, slideshow): + self.servicemgr = servicemgr + self.slideshow = slideshow - def handle(self, app, action): - j = self.getRequest().json - if action == 'available': - return self.jsonify(self.servicemgr.listServices()) - if action == 'list': - return self.jsonify(self.servicemgr.getServices()) - if action == 'add' and j is not None: - if 'name' in j and 'id' in j: - old = self.servicemgr.hasReadyServices() - svcid = self.servicemgr.addService(int(j['id']), j['name']) - if old != self.servicemgr.hasReadyServices(): - self.slideshow.trigger() - return self.jsonify({'id':svcid}) - if action == 'remove' and j is not None: - if 'id' in j: - self.servicemgr.deleteService(j['id']) - self.slideshow.trigger() # Always trigger since we don't know who was on-screen - return self.jsonify({'status':'Done'}) - if action == 'rename' and j is not None: - if 'name' in j and 'id' in j: - if self.servicemgr.renameService(j['id'], j['name']): - return self.jsonify({'status':'Done'}) - if self.getRequest().url.endswith('/config/fields'): - return self.jsonify(self.servicemgr.getServiceConfigurationFields(id)) - if self.getRequest().url.endswith('/config'): - if self.getRequest().method == 'POST' and j is not None and 'config' in j: - if self.servicemgr.setServiceConfiguration(id, j['config']): - return 'Configuration saved', 200 - elif self.getRequest().method == 'GET': - return self.jsonify(self.servicemgr.getServiceConfiguration(id)) - self.setAbort(500) + self.addUrl('/service/').addMethod('POST') + def handle(self, app, action): + j = self.getRequest().json + if action == 'available': + return self.jsonify(self.servicemgr.listServices()) + if action == 'list': + return self.jsonify(self.servicemgr.getServices()) + if action == 'add' and j is not None: + if 'name' in j and 'id' in j: + old = self.servicemgr.hasReadyServices() + svcid = self.servicemgr.addService(int(j['id']), j['name']) + if old != self.servicemgr.hasReadyServices(): + self.slideshow.trigger() + return self.jsonify({'id': svcid}) + if action == 'remove' and j is not None: + if 'id' in j: + self.servicemgr.deleteService(j['id']) + self.slideshow.trigger() # Always trigger since we don't know who was on-screen + return self.jsonify({'status': 'Done'}) + if action == 'rename' and j is not None: + if 'name' in j and 'id' in j: + if self.servicemgr.renameService(j['id'], j['name']): + return self.jsonify({'status': 'Done'}) + if self.getRequest().url.endswith('/config/fields'): + return self.jsonify(self.servicemgr.getServiceConfigurationFields(id)) + if self.getRequest().url.endswith('/config'): + if self.getRequest().method == 'POST' and j is not None and 'config' in j: + if self.servicemgr.setServiceConfiguration(id, j['config']): + return 'Configuration saved', 200 + elif self.getRequest().method == 'GET': + return self.jsonify(self.servicemgr.getServiceConfiguration(id)) + self.setAbort(500) diff --git a/routes/settings.py b/routes/settings.py index db9a058..edbe905 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -18,74 +18,78 @@ from modules.helper import helper from modules.shutdown import shutdown + class RouteSettings(BaseRoute): - def setupex(self, powermanagement, settingsMgr, drivermgr, timekeeper, display, cachemgr, slideshow): - self.powermanagement = powermanagement - self.settingsMgr = settingsMgr - self.driverMgr = drivermgr - self.timekeeper = timekeeper - self.display = display - self.cachemgr = cachemgr - self.slideshow = slideshow + def setupex(self, powermanagement, settingsMgr, drivermgr, timekeeper, display, cachemgr, slideshow): + self.powermanagement = powermanagement + self.settingsMgr = settingsMgr + self.driverMgr = drivermgr + self.timekeeper = timekeeper + self.display = display + self.cachemgr = cachemgr + self.slideshow = slideshow - self.addUrl('/setting').addDefault('key', None).addDefault('value', None) - self.addUrl('/setting/').addDefault('value', None) - self.addUrl('/setting//').clearMethods().addMethod('PUT') + self.addUrl('/setting').addDefault('key', None).addDefault('value', None) + self.addUrl('/setting/').addDefault('value', None) + self.addUrl('/setting//').clearMethods().addMethod('PUT') - def handle(self, app, key, value): - # Depending on PUT/GET we will either change or read - # values. If key is unknown, then this call fails with 404 - if key is not None: - if self.settingsMgr.getUser(key) is None: - self.abort(404) - return + def handle(self, app, key, value): + # Depending on PUT/GET we will either change or read + # values. If key is unknown, then this call fails with 404 + if key is not None: + if self.settingsMgr.getUser(key) is None: + self.abort(404) + return - if self.getRequest().method == 'PUT': - status = True - if key == "keywords": - # Keywords has its own API - self.setAbort(404) - return - self.settingsMgr.setUser(key, value) - if key in ['display-driver']: - drv = self.settingsMgr.getUser('display-driver') - if drv == 'none': - drv = None - special = self.driverMgr.activate(drv) - if special is None: - self.settingsMgr.setUser('display-driver', 'none') - self.settingsMgr.setUser('display-special', None) - status = False - else: - self.settingsMgr.setUser('display-special', special) - if key in ['timezone']: - # Make sure we convert + to / - self.settingsMgr.setUser('timezone', value.replace('+', '/')) - helper.timezoneSet(self.settingsMgr.getUser('timezone')) - if key in ['resolution', 'tvservice']: - width, height, tvservice = self.display.setConfiguration(value, self.settingsMgr.getUser('display-special')) - self.settingsMgr.setUser('tvservice', tvservice) - self.settingsMgr.setUser('width', width) - self.settingsMgr.setUser('height', height) - self.display.enable(True, True) - self.cachemgr.empty() - if key in ['display-on', 'display-off']: - self.timekeeper.setConfiguration(self.settingsMgr.getUser('display-on'), self.settingsMgr.getUser('display-off')) - if key in ['autooff-lux', 'autooff-time']: - self.timekeeper.setAmbientSensitivity(self.settingsMgr.getUser('autooff-lux'), self.settingsMgr.getUser('autooff-time')) - if key in ['powersave']: - self.timekeeper.setPowermode(self.settingsMgr.getUser('powersave')) - if key in ['shutdown-pin']: - self.powermanagement.stopmonitor() - self.powermanagement = shutdown(self.settingsMgr.getUser('shutdown-pin')) - if key in ['imagesizing', 'randomize_images']: - self.slideshow.createEvent("settingsChange") - self.settingsMgr.save() - return self.jsonify({'status':status}) + if self.getRequest().method == 'PUT': + status = True + if key == "keywords": + # Keywords has its own API + self.setAbort(404) + return + self.settingsMgr.setUser(key, value) + if key in ['display-driver']: + drv = self.settingsMgr.getUser('display-driver') + if drv == 'none': + drv = None + special = self.driverMgr.activate(drv) + if special is None: + self.settingsMgr.setUser('display-driver', 'none') + self.settingsMgr.setUser('display-special', None) + status = False + else: + self.settingsMgr.setUser('display-special', special) + if key in ['timezone']: + # Make sure we convert + to / + self.settingsMgr.setUser('timezone', value.replace('+', '/')) + helper.timezoneSet(self.settingsMgr.getUser('timezone')) + if key in ['resolution', 'tvservice']: + width, height, tvservice = self.display.setConfiguration( + value, self.settingsMgr.getUser('display-special')) + self.settingsMgr.setUser('tvservice', tvservice) + self.settingsMgr.setUser('width', width) + self.settingsMgr.setUser('height', height) + self.display.enable(True, True) + self.cachemgr.empty() + if key in ['display-on', 'display-off']: + self.timekeeper.setConfiguration(self.settingsMgr.getUser( + 'display-on'), self.settingsMgr.getUser('display-off')) + if key in ['autooff-lux', 'autooff-time']: + self.timekeeper.setAmbientSensitivity(self.settingsMgr.getUser( + 'autooff-lux'), self.settingsMgr.getUser('autooff-time')) + if key in ['powersave']: + self.timekeeper.setPowermode(self.settingsMgr.getUser('powersave')) + if key in ['shutdown-pin']: + self.powermanagement.stopmonitor() + self.powermanagement = shutdown(self.settingsMgr.getUser('shutdown-pin')) + if key in ['imagesizing', 'randomize_images']: + self.slideshow.createEvent("settingsChange") + self.settingsMgr.save() + return self.jsonify({'status': status}) - elif self.getRequest().method == 'GET': - if key is None: - return self.jsonify(self.settingsMgr.getUser()) - else: - return self.jsonify({key : self.settingsMgr.getUser(key)}) - self.setAbort(404) + elif self.getRequest().method == 'GET': + if key is None: + return self.jsonify(self.settingsMgr.getUser()) + else: + return self.jsonify({key: self.settingsMgr.getUser(key)}) + self.setAbort(404) diff --git a/routes/upload.py b/routes/upload.py index 8d29dba..0edc475 100644 --- a/routes/upload.py +++ b/routes/upload.py @@ -20,54 +20,55 @@ from werkzeug.utils import secure_filename from .baseroute import BaseRoute + class RouteUpload(BaseRoute): - def setupex(self, settingsmgr, drivermgr): - self.settingsmgr = settingsmgr - self.drivermgr = drivermgr + def setupex(self, settingsmgr, drivermgr): + self.settingsmgr = settingsmgr + self.drivermgr = drivermgr - self.addUrl('/upload/').clearMethods().addMethod('POST') + self.addUrl('/upload/').clearMethods().addMethod('POST') - def handle(self, app, item): - retval = {'status':200, 'return':{}} - # check if the post request has the file part - if 'filename' not in self.getRequest().files: - logging.error('No file part') - self.setAbort(405) - return + def handle(self, app, item): + retval = {'status': 200, 'return': {}} + # check if the post request has the file part + if 'filename' not in self.getRequest().files: + logging.error('No file part') + self.setAbort(405) + return - file = self.getRequest().files['filename'] - if item == 'driver': - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '' or not file.filename.lower().endswith('.zip'): - logging.error('No filename or invalid filename') - self.setAbort(405) - return + file = self.getRequest().files['filename'] + if item == 'driver': + # if user does not select file, browser also + # submit an empty part without filename + if file.filename == '' or not file.filename.lower().endswith('.zip'): + logging.error('No filename or invalid filename') + self.setAbort(405) + return - filename = os.path.join('/tmp/', secure_filename(file.filename)) - file.save(filename) + filename = os.path.join('/tmp/', secure_filename(file.filename)) + file.save(filename) - if item == 'driver': - result = self.drivermgr.install(filename) - if result is not False: - # Check and see if this is the driver we're using - if result['driver'] == self.settingsmgr.getUser('display-driver'): - # Yes it is, we need to activate it and return info about restarting - special = self.drivermgr.activate(result['driver']) - if special is None: - self.settingsmgr.setUser('display-driver', 'none') - self.settingsmgr.setUser('display-special', None) - retval['status'] = 500 - else: - self.settingsmgr.setUser('display-special', special) - retval['return'] = {'reboot' : True} - else: - retval['return'] = {'reboot' : False} + if item == 'driver': + result = self.drivermgr.install(filename) + if result is not False: + # Check and see if this is the driver we're using + if result['driver'] == self.settingsmgr.getUser('display-driver'): + # Yes it is, we need to activate it and return info about restarting + special = self.drivermgr.activate(result['driver']) + if special is None: + self.settingsmgr.setUser('display-driver', 'none') + self.settingsmgr.setUser('display-special', None) + retval['status'] = 500 + else: + self.settingsmgr.setUser('display-special', special) + retval['return'] = {'reboot': True} + else: + retval['return'] = {'reboot': False} - try: - os.remove(filename) - except: - pass - if retval['status'] == 200: - return self.jsonify(retval['return']) - self.setAbort(retval['status']) + try: + os.remove(filename) + except: + pass + if retval['status'] == 200: + return self.jsonify(retval['return']) + self.setAbort(retval['status']) diff --git a/services/base.py b/services/base.py index d3b1c6d..61d6c3e 100755 --- a/services/base.py +++ b/services/base.py @@ -43,772 +43,790 @@ # # Use the exposed functions as needed to get the data you want. # + + class BaseService: - REFRESH_DELAY = 60*60 # Number of seconds before we refresh the index in case no photos - SERVICE_DEPRECATED = False - - STATE_ERROR = -1 - STATE_UNINITIALIZED = 0 - - STATE_DO_CONFIG = 1 - STATE_DO_OAUTH = 2 - STATE_NEED_KEYWORDS = 3 - STATE_NO_IMAGES = 4 - - STATE_READY = 999 - - def __init__(self, configDir, id, name, needConfig=False, needOAuth=False): - # MUST BE CALLED BY THE IMPLEMENTING CLASS! - self._ID = id - self._NAME = name - self._OAUTH = None - self._CACHEMGR = None - - self._CURRENT_STATE = BaseService.STATE_UNINITIALIZED - self._ERROR = None - - # NUM_IMAGES keeps track of how many images are being provided by each keyword - # As for now, unsupported images (mimetype, orientation) and already displayed images are NOT excluded due to simplicity, - # but it should still serve as a rough estimate to ensure that every image has a similar chance of being shown in "random_image_mode"! - # NEXT_SCAN is used to determine when a keyword should be re-indexed. This used in the case number of photos are zero to avoid hammering - # services. - self._STATE = { - '_OAUTH_CONFIG' : None, - '_OAUTH_CONTEXT' : None, - '_CONFIG' : None, - '_KEYWORDS' : [], - '_NUM_IMAGES' : {}, - '_NEXT_SCAN' : {}, - '_EXTRAS' : None, - '_INDEX_IMAGE' : 0, - '_INDEX_KEYWORD' : 0 - } - self._NEED_CONFIG = needConfig - self._NEED_OAUTH = needOAuth - - self._DIR_BASE = self._prepareFolders(configDir) - self._DIR_PRIVATE = os.path.join(self._DIR_BASE, 'private') - self._FILE_STATE = os.path.join(self._DIR_BASE, 'state.json') - - self.memory = MemoryManager(os.path.join(self._DIR_BASE, 'memory')) - - self.loadState() - self.preSetup() - - def setCacheManager(self, cacheMgr): - self._CACHEMGR = cacheMgr - - def _prepareFolders(self, configDir): - basedir = os.path.join(configDir, self._ID) - if not os.path.exists(basedir): - os.mkdir(basedir) - if not os.path.exists(basedir + '/memory'): - os.mkdir(basedir + '/memory') - if not os.path.exists(basedir + '/private'): - os.mkdir(basedir + '/private') - return basedir - - ###[ Used by service to do any kind of house keeping ]########################### - - def preSetup(self): - # If you need to do anything before initializing, override this - # NOTE! No auth or oauth has been done at this point, only state has been loaded - pass - - def postSetup(self): - # If you need to do anything right after initializing, override this - # NOTE! At this point, any auth and/or oauth will have been performed. State is not saved after this call - pass - - ###[ Used by photoframe to determinte what to do next ]########################### - - def updateState(self): - # Determines what the user needs to do next to configure this service - # if this doesn't return ready, caller must take appropiate action - if self._NEED_OAUTH and self._OAUTH is None: - self._OAUTH = OAuth(self._setOAuthToken, self._getOAuthToken, self.getOAuthScope(), self._ID) - if self._STATE['_OAUTH_CONFIG'] is not None: - self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG']) - self.postSetup() - - if self._NEED_CONFIG and not self.hasConfiguration(): - self._CURRENT_STATE = BaseService.STATE_DO_CONFIG - elif self._NEED_OAUTH and (not self.hasOAuthConfig or not self.hasOAuth()): - self._CURRENT_STATE = BaseService.STATE_DO_OAUTH - elif self.needKeywords() and len(self.getKeywords()) == 0: - self._CURRENT_STATE = BaseService.STATE_NEED_KEYWORDS - elif self.getImagesTotal() == 0: - self._CURRENT_STATE = BaseService.STATE_NO_IMAGES - else: - self._CURRENT_STATE = BaseService.STATE_READY - - return self._CURRENT_STATE - - ###[ Allows loading/saving of service state ]########################### - - def loadState(self): - # Load any stored state data from storage - # Normally you don't override this - if os.path.exists(self._FILE_STATE): - try: - with open(self._FILE_STATE, 'r') as f: - self._STATE.update( json.load(f) ) - except: - logging.exception('Unable to load state for service') - os.unlink(self._FILE_STATE) - - def saveState(self): - # Stores the state data under the unique ID for - # this service provider's instance - # normally you don't override this - with open(self._FILE_STATE, 'w') as f: - json.dump(self._STATE, f) - - ###[ Get info about instance ]########################### - - def getName(self): - # Retrieves the name of this instance - return self._NAME - - def setName(self, newName): - self._NAME = newName - - def getId(self): - return self._ID - - def getImagesTotal(self): - # return the total number of images provided by this service - if self.needKeywords(): - for keyword in self.getKeywords(): - if keyword not in self._STATE["_NUM_IMAGES"] or keyword not in self._STATE['_NEXT_SCAN'] or self._STATE['_NEXT_SCAN'][keyword] < time.time(): - logging.debug('Keywords either not scanned or we need to scan now') - self._getImagesFor(keyword) # Will make sure to get images - self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY - return sum([self._STATE["_NUM_IMAGES"][k] for k in self._STATE["_NUM_IMAGES"]]) - - def getImagesSeen(self): - count = 0 - if self.needKeywords(): - for keyword in self.getKeywords(): - count += self.memory.count(keyword) - return count - - def getImagesRemaining(self): - return self.getImagesTotal() - self.getImagesSeen() - - def getMessages(self): - # override this if you wish to show a message associated with - # the provider's instance. Return None to hide - # Format: [{'level' : 'INFO', 'message' : None, 'link' : None}] - msgs = [] - if self._CURRENT_STATE in [self.STATE_NEED_KEYWORDS]: # , self.STATE_NO_IMAGES]: - msgs.append( - { - 'level': 'INFO', - 'message' : 'Please add one or more items in order to show photos from this provider (see help button)', - 'link': None - } - ) - if 0 in list(self._STATE["_NUM_IMAGES"].values()): - # Find first keyword with zero (unicode issue) - removeme = [] - for keyword in self._STATE["_KEYWORDS"]: - if self._STATE["_NUM_IMAGES"][keyword] == 0: - removeme.append(keyword) - msgs.append( - { - 'level': 'WARNING', - 'message': 'The following keyword(s) do not yield any photos: %s' % ', '.join(map('"{0}"'.format, removeme)), - 'link': None - } - ) - return msgs - - def explainState(self): - # override this if you wish to show additional on-screen information for a specific state - # return String - return None - - ###[ All the OAuth functionality ]########################### - - def getOAuthScope(self): - # *Override* to define any needed OAuth scope - # must return array of string(s) - return None - - def setOAuthConfig(self, config): - # Provides OAuth config data for linking. - # Without this information, OAuth cannot be done. - # If config is invalid, returns False - self._STATE['_OAUTH_CONFIG'] = config - if self._OAUTH is not None: - self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG']) - self.postSetup() - - self.saveState() - return True - - def helpOAuthConfig(self): - return 'Should explain what kind of content to provide' - - def hasOAuthConfig(self): - # Returns true/false if we have a config for oauth - return self._STATE['_OAUTH_CONFIG'] is not None - - def hasOAuth(self): - # Tests if we have a functional OAuth link, - # returns False if we need to set it up - return self._STATE['_OAUTH_CONTEXT'] is not None - - def invalidateOAuth(self): - # Removes previously negotiated OAuth - self._STATE['_OAUTH_CONFIG'] = None - self._STATE['_OAUTH_CONTEXT'] = None - self.saveState() - - def startOAuth(self): - # Returns a HTTP redirect to begin OAuth or None if - # oauth isn't configured. Normally not overriden - return self._OAUTH.initiate() - - def finishOAuth(self, url): - # Called when OAuth sequence has completed - self._OAUTH.complete(url) - self.saveState() - - def _setOAuthToken(self, token): - self._STATE['_OAUTH_CONTEXT'] = token - self.saveState() - - def _getOAuthToken(self): - return self._STATE['_OAUTH_CONTEXT'] - - def migrateOAuthToken(self, token): - if self._STATE['_OAUTH_CONTEXT'] is not None: - logging.error('Cannot migrate token, already have one!') - return - logging.debug('Setting token to %s' % repr(token)) - self._STATE['_OAUTH_CONTEXT'] = token - self.saveState() - - ###[ For services which require static auth ]########################### - - def validateConfiguration(self, config): - # Allow service to validate config, if correct, return None - # If incorrect, return helpful error message. - # config is a map with fields and their values - return 'Not overriden yet but config is enabled' - - def setConfiguration(self, config): - # Setup any needed authentication data for this - # service. - self._STATE['_CONFIG'] = config - self.saveState() - - def getConfiguration(self): - return self._STATE['_CONFIG'] - - def hasConfiguration(self): - # Checks if it has auth data - return self._STATE['_CONFIG'] != None - - def getConfigurationFields(self): - # Returns a key/value map with: - # "field" => [ "type" => "STR/INT", "name" => "Human readable", "description" => "Longer text" ] - # Allowing UX to be dynamically created - # Supported field types are: STR, INT, PW (password means it will obscure it on input) - return {'username' : {'type':'STR', 'name':'Username', 'description':'Username to use for login'}} - - ###[ Keyword management ]########################### - - def validateKeywords(self, keywords): - # Quick check, don't allow duplicates! - if keywords in self.getKeywords(): - logging.error('Keyword is already in list') - return {'error': 'Keyword already in list', 'keywords': keywords} - - return {'error':None, 'keywords': keywords} - - def addKeywords(self, keywords): - # This is how the user will configure it, this adds a new set of keywords to this - # service module. Return none on success, string with error on failure - keywords = keywords.strip() - - if not self.needKeywords(): - return {'error' : 'Doesn\'t use keywords', 'keywords' : keywords} - if keywords == '': - return {'error' : 'Keyword string cannot be empty', 'kewords' : keywords} - - tst = self.validateKeywords(keywords) - if tst['error'] is None: - keywords = tst['keywords'] - self._STATE['_KEYWORDS'].append(keywords) - self.saveState() - return tst - - def getKeywords(self): - # Returns an array of all keywords - return self._STATE['_KEYWORDS'] - - def getKeywordSourceUrl(self, index): - # Override to provide a source link - return None - - def getKeywordDetails(self, index): - # Override so we can tell more - # Format of data is: - # ('short': short, 'long' : ["line1", "line2", ...]) where short is a string and long is a string array - return None - - def hasKeywordDetails(self): - # Override so we can tell more - return False - - def hasKeywordSourceUrl(self): - # Override to provide source url support - return False - - def removeKeywords(self, index): - if index < 0 or index > (len(self._STATE['_KEYWORDS'])-1): - logging.error('removeKeywords: Out of range %d' % index) - return False - kw = self._STATE['_KEYWORDS'].pop(index) - if kw in self._STATE['_NUM_IMAGES']: - del self._STATE['_NUM_IMAGES'][kw] - self.saveState() - # Also kill the memory of this keyword - self.memory.forget(kw) - return True - - def needKeywords(self): - # Some services don't have keywords. Override this to return false - # to remove the keywords options. - return True - - def helpKeywords(self): - return 'Has not been defined' - - def getRandomKeywordIndex(self): - # select keyword index at random but weighted by the number of images of each album - totalImages = self.getImagesTotal() - if totalImages == 0: - return 0 - numImages = [self._STATE['_NUM_IMAGES'][kw] for kw in self._STATE['_NUM_IMAGES']] - return helper.getWeightedRandomIndex(numImages) - - def getKeywordLink(self, index): - if index < 0 or index > (len(self._STATE['_KEYWORDS'])-1): - logging.error('removeKeywords: Out of range %d' % index) - return - - ###[ Extras - Allows easy access to config ]################# - - def getExtras(self): - return self._STATE['_EXTRAS'] - - def setExtras(self, data): - self._STATE['_EXTRAS'] = data - self.saveState() - - ###[ Actual hard work ]########################### - - def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize, randomize): - # This call requires the service to download the next item it - # would like to show. The destinationFile has to be used as where to save it - # and you are only allowed to provide content listed in the supportedMimeTypes. - # displaySize holds the keys width & height to provide a hint for the service to avoid downloading HUGE files - # Return for this function is a key/value map with the following MANDATORY - # fields: - # "id" : a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl) - # "mimetype" : the filetype you downloaded, for example "image/jpeg" - # "error" : None or a human readable text string as to why you failed - # "source" : Link to where the item came from or None if not provided - # - # NOTE! If you need to index anything before you can get the first item, this would - # also be the place to do it. - # - # If your service uses keywords (as albums) 'selectImageFromAlbum' of the baseService class should do most of the work for you - # You will probably only need to implement 'getImagesFor' and 'addUrlParams' - - if self.needKeywords(): - if len(self.getKeywords()) == 0: - return ImageHolder().setError('No albums have been specified') - - if randomize: - result = self.selectRandomImageFromAlbum(destinationFile, supportedMimeTypes, displaySize) - else: - result = self.selectNextImageFromAlbum(destinationFile, supportedMimeTypes, displaySize) - if result is None: - result = ImageHolder().setError('No (new) images could be found') - else: - result = ImageHolder().setError('prepareNextItem() not implemented') - - return result - - def _getImagesFor(self, keyword): - images = self.getImagesFor(keyword) - if images is None: - logging.warning('Function returned None, this is used sometimes when a temporary error happens. Still logged') - - if images is not None and len(images) > 0: - self._STATE["_NUM_IMAGES"][keyword] = len(images) - # Change next time for refresh (postpone if you will) - self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY - else: - self._STATE["_NUM_IMAGES"][keyword] = 0 - return images - - def getImagesFor(self, keyword): - # You need to override this function if your service needs keywords and - # you want to use 'selectImageFromAlbum' of the baseService class - # This function should collect data about all images matching a specific keyword - # Return for this function is a list of multiple key/value maps each containing the following MANDATORY fields: - # "id": a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl) - # "url": Link to the actual image file - # "sources": Link to where the item came from or None if not provided - # "mimetype": the filetype of the image, for example "image/jpeg" - # can be None, but you should catch unsupported mimetypes after the image has downloaded (example: svc_simpleurl.py) - # "size": a key/value map containing "width" and "height" of the image - # can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams' - # "filename": the original filename of the image or None if unknown (only used for debugging purposes) - # "error": If present, will generate an error shown to the user with the text within this key as the message - - return [ ImageHolder().setError('getImagesFor() not implemented') ] - - def _clearImagesFor(self, keyword): - self._STATE["_NUM_IMAGES"].pop(keyword, None) - self._STATE['_NEXT_SCAN'].pop(keyword, None) - self.memory.forget(keyword) - self.clearImagesFor(keyword) - - def clearImagesFor(self, keyword): - # You can hook this function to do any additional needed cleanup - # keyword is the item for which you need to clear the images for - pass - - def freshnessImagesFor(self, keyword): - # You need to implement this function if you intend to support refresh of content - # keyword is the item for which you need to clear the images for. Should return age of content in hours - return 0 - - def getContentUrl(self, image, hints): - # Allows a content provider to do extra operations as needed to - # extract the correct URL. - # - # image is an image object - # - # hints is a map holding various hints to be used by the content provider. - # "size" holds "width" and "height" of the ideal image dimensions based on display size - # "display" holds "width" and "height" of the physical display - # - # By default, if you don't override this, it will simply use the image.url as the return - return image.url - - ###[ Helpers ]###################################### - - def selectRandomImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize): - # chooses an album and selects an image from that album. Returns an image object or None - # if no images are available. - - keywords = self.getKeywords() - index = self.getRandomKeywordIndex() - - # if current keywordList[index] does not contain any new images --> just run through all albums - for i in range(0, len(keywords)): - self.setIndex(keyword = (index + i) % len(keywords)) - keyword = keywords[self.getIndexKeyword()] - - # a provider-specific implementation for 'getImagesFor' is obligatory! - # We use a wrapper to clear things up - images = self._getImagesFor(keyword) - if images is None or len(images) == 0: - self.setIndex(0) - continue - elif images[0].error is not None: - # Something went wrong, only return first image since it holds the error - return images[0] - self.saveState() - - image = self.selectRandomImage(keyword, images, supportedMimeTypes, displaySize) - if image is None: - self.setIndex(0) - continue - - return self.fetchImage(image, destinationDir, supportedMimeTypes, displaySize) - return None - - def generateFilename(self): - return str(uuid.uuid4()) - - def fetchImage(self, image, destinationDir, supportedMimeTypes, displaySize): - filename = os.path.join(destinationDir, self.generateFilename()) - - if image.cacheAllow: - # Look it up in the cache mgr - if self._CACHEMGR is None: - logging.error('CacheManager is not available') - else: - cacheFile = self._CACHEMGR.getCachedImage(image.getCacheId(), filename) - if cacheFile: - image.setFilename(cacheFile) - image.cacheUsed = True - - if not image.cacheUsed: - recommendedSize = self.calcRecommendedSize(image.dimensions, displaySize) - if recommendedSize is None: - recommendedSize = displaySize - url = self.getContentUrl(image, {'size' : recommendedSize, 'display' : displaySize}) - if url is None: - return ImageHolder().setError('Unable to download image, no URL') - - try: - result = self.requestUrl(url, destination=filename) - except (RequestResult.RequestExpiredToken, RequestInvalidToken): - logging.exception('Cannot fetch due to token issues') - result = RequestResult().setResult(RequestResult.OAUTH_INVALID) + REFRESH_DELAY = 60*60 # Number of seconds before we refresh the index in case no photos + SERVICE_DEPRECATED = False + + STATE_ERROR = -1 + STATE_UNINITIALIZED = 0 + + STATE_DO_CONFIG = 1 + STATE_DO_OAUTH = 2 + STATE_NEED_KEYWORDS = 3 + STATE_NO_IMAGES = 4 + + STATE_READY = 999 + + def __init__(self, configDir, id, name, needConfig=False, needOAuth=False): + # MUST BE CALLED BY THE IMPLEMENTING CLASS! + self._ID = id + self._NAME = name self._OAUTH = None - except requests.exceptions.RequestException: - logging.exception('request to download image failed') - result = RequestResult().setResult(RequestResult.NO_NETWORK) - - if not result.isSuccess(): - return ImageHolder().setError('%d: Unable to download image!' % result.httpcode) - else: - image.setFilename(filename) - if image.filename is not None: - image.setMimetype(helper.getMimetype(image.filename)) - return image - - def selectNextImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize): - # chooses an album and selects an image from that album. Returns an image object or None - # if no images are available. - - keywordList = self.getKeywords() - keywordCount = len(keywordList) - index = self.getIndexKeyword() - - # if current keywordList[index] does not contain any new images --> just run through all albums - for i in range(0, keywordCount): - if (index + i) >= keywordCount: - # (non-random image order): return if the last album is exceeded --> serviceManager should use next service - break - self.setIndex(keyword = (index + i) % keywordCount) - keyword = keywordList[self.getIndexKeyword()] - - # a provider-specific implementation for 'getImagesFor' is obligatory! - # We use a wrapper to clear things up - images = self._getImagesFor(keyword) - if images is None or len(images) == 0: - self.setIndex(0) - continue - elif images[0].error is not None: - # Something went wrong, only return first image since it holds the error - return images[0] - self.saveState() - - image = self.selectNextImage(keyword, images, supportedMimeTypes, displaySize) - if image is None: - self.setIndex(0) - continue - - return self.fetchImage(image, destinationDir, supportedMimeTypes, displaySize) - return None - - def selectRandomImage(self, keywords, images, supportedMimeTypes, displaySize): - imageCount = len(images) - index = random.SystemRandom().randint(0, imageCount-1) - - logging.debug('There are %d images total' % imageCount) - for i in range(0, imageCount): - image = images[(index + i) % imageCount] - - orgFilename = image.filename if image.filename is not None else image.id - if self.memory.seen(image.id, keywords): - logging.debug("Skipping already displayed image '%s'!" % orgFilename) - continue - - # No matter what, we need to track that we considered this image - self.memory.remember(image.id, keywords) - - if not self.isCorrectOrientation(image.dimensions, displaySize): - logging.debug("Skipping image '%s' due to wrong orientation!" % orgFilename) - continue - if image.mimetype is not None and image.mimetype not in supportedMimeTypes: - # Make sure we don't get a video, unsupported for now (gif is usually bad too) - logging.debug('Skipping unsupported media: %s' % (image.mimetype)) - continue - - self.setIndex((index + i) % imageCount) - return image - return None - - def selectNextImage(self, keywords, images, supportedMimeTypes, displaySize): - imageCount = len(images) - index = self.getIndexImage() - - for i in range(index, imageCount): - image = images[i] - - orgFilename = image.filename if image.filename is not None else image.id - if self.memory.seen(image.id, keywords): - logging.debug("Skipping already displayed image '%s'!" % orgFilename) - continue - - # No matter what, we need to track that we considered this image - self.memory.remember(image.id, keywords) - - if not self.isCorrectOrientation(image.dimensions, displaySize): - logging.debug("Skipping image '%s' due to wrong orientation!" % orgFilename) - continue - if image.mimetype is not None and image.mimetype not in supportedMimeTypes: - # Make sure we don't get a video, unsupported for now (gif is usually bad too) - logging.debug('Skipping unsupported media: %s' % (image.mimetype)) - continue - - self.setIndex(i) - return image - return None - - def requestUrl(self, url, destination=None, params=None, data=None, usePost=False): - result = RequestResult() - - if self._OAUTH is not None: - # Use OAuth path - try: - result = self._OAUTH.request(url, destination, params, data=data, usePost=usePost) - except (RequestExpiredToken, RequestInvalidToken): - logging.exception('Cannot fetch due to token issues') - result = RequestResult().setResult(RequestResult.OAUTH_INVALID) - self.invalidateOAuth() - except requests.exceptions.RequestException: - logging.exception('request to download image failed') - result = RequestResult().setResult(RequestResult.NO_NETWORK) - else: - tries = 0 - while tries < 5: - try: - if usePost: - r = requests.post(url, params=params, json=data, timeout=180) - else: - r = requests.get(url, params=params, timeout=180) - break - except: - logging.exception('Issues downloading') - time.sleep(tries / 10) # Back off 10, 20, ... depending on tries - tries += 1 - logging.warning('Retrying again, attempt #%d', tries) - - if tries == 5: - logging.error('Failed to download due to network issues') - raise RequestNoNetwork - - if r: - result.setHTTPCode(r.status_code).setHeaders(r.headers).setResult(RequestResult.SUCCESS) - - if destination is None: - result.setContent(r.content) + self._CACHEMGR = None + + self._CURRENT_STATE = BaseService.STATE_UNINITIALIZED + self._ERROR = None + + # NUM_IMAGES keeps track of how many images are being provided by each keyword + # As for now, unsupported images (mimetype, orientation) and already displayed + # images are NOT excluded due to simplicity, but it should still serve as a rough + # estimate to ensure that every image has a similar chance of being shown in + # "random_image_mode"! + # NEXT_SCAN is used to determine when a keyword should be re-indexed. + # This used in the case number of photos are zero to avoid hammering services. + self._STATE = { + '_OAUTH_CONFIG': None, + '_OAUTH_CONTEXT': None, + '_CONFIG': None, + '_KEYWORDS': [], + '_NUM_IMAGES': {}, + '_NEXT_SCAN': {}, + '_EXTRAS': None, + '_INDEX_IMAGE': 0, + '_INDEX_KEYWORD': 0 + } + self._NEED_CONFIG = needConfig + self._NEED_OAUTH = needOAuth + + self._DIR_BASE = self._prepareFolders(configDir) + self._DIR_PRIVATE = os.path.join(self._DIR_BASE, 'private') + self._FILE_STATE = os.path.join(self._DIR_BASE, 'state.json') + + self.memory = MemoryManager(os.path.join(self._DIR_BASE, 'memory')) + + self.loadState() + self.preSetup() + + def setCacheManager(self, cacheMgr): + self._CACHEMGR = cacheMgr + + def _prepareFolders(self, configDir): + basedir = os.path.join(configDir, self._ID) + if not os.path.exists(basedir): + os.mkdir(basedir) + if not os.path.exists(basedir + '/memory'): + os.mkdir(basedir + '/memory') + if not os.path.exists(basedir + '/private'): + os.mkdir(basedir + '/private') + return basedir + + # Used by service to do any kind of house keeping: + + def preSetup(self): + # If you need to do anything before initializing, override this + # NOTE! No auth or oauth has been done at this point, only state has been loaded + pass + + def postSetup(self): + # If you need to do anything right after initializing, override this + # NOTE! At this point, any auth and/or oauth will have been performed. State is not saved after this call + pass + + # Used by photoframe to determinte what to do next: + + def updateState(self): + # Determines what the user needs to do next to configure this service + # if this doesn't return ready, caller must take appropiate action + if self._NEED_OAUTH and self._OAUTH is None: + self._OAUTH = OAuth(self._setOAuthToken, self._getOAuthToken, self.getOAuthScope(), self._ID) + if self._STATE['_OAUTH_CONFIG'] is not None: + self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG']) + self.postSetup() + + if self._NEED_CONFIG and not self.hasConfiguration(): + self._CURRENT_STATE = BaseService.STATE_DO_CONFIG + elif self._NEED_OAUTH and (not self.hasOAuthConfig or not self.hasOAuth()): + self._CURRENT_STATE = BaseService.STATE_DO_OAUTH + elif self.needKeywords() and len(self.getKeywords()) == 0: + self._CURRENT_STATE = BaseService.STATE_NEED_KEYWORDS + elif self.getImagesTotal() == 0: + self._CURRENT_STATE = BaseService.STATE_NO_IMAGES + else: + self._CURRENT_STATE = BaseService.STATE_READY + + return self._CURRENT_STATE + + # Allows loading/saving of service state: + + def loadState(self): + # Load any stored state data from storage + # Normally you don't override this + if os.path.exists(self._FILE_STATE): + try: + with open(self._FILE_STATE, 'r') as f: + self._STATE.update(json.load(f)) + except Exception: + logging.exception('Unable to load state for service') + os.unlink(self._FILE_STATE) + + def saveState(self): + # Stores the state data under the unique ID for + # this service provider's instance + # normally you don't override this + with open(self._FILE_STATE, 'w') as f: + json.dump(self._STATE, f) + + # Get info about instance: + + def getName(self): + # Retrieves the name of this instance + return self._NAME + + def setName(self, newName): + self._NAME = newName + + def getId(self): + return self._ID + + def getImagesTotal(self): + # return the total number of images provided by this service + if self.needKeywords(): + for keyword in self.getKeywords(): + if keyword not in self._STATE["_NUM_IMAGES"] or keyword not in self._STATE['_NEXT_SCAN'] \ + or self._STATE['_NEXT_SCAN'][keyword] < time.time(): + + logging.debug('Keywords either not scanned or we need to scan now') + self._getImagesFor(keyword) # Will make sure to get images + self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY + return sum([self._STATE["_NUM_IMAGES"][k] for k in self._STATE["_NUM_IMAGES"]]) + + def getImagesSeen(self): + count = 0 + if self.needKeywords(): + for keyword in self.getKeywords(): + count += self.memory.count(keyword) + return count + + def getImagesRemaining(self): + return self.getImagesTotal() - self.getImagesSeen() + + def getMessages(self): + # override this if you wish to show a message associated with + # the provider's instance. Return None to hide + # Format: [{'level' : 'INFO', 'message' : None, 'link' : None}] + msgs = [] + if self._CURRENT_STATE in [self.STATE_NEED_KEYWORDS]: # , self.STATE_NO_IMAGES]: + msgs.append( + { + 'level': 'INFO', + 'message': 'Please add one or more items in order to show photos from this provider ' + '(see help button)', + 'link': None + } + ) + if 0 in list(self._STATE["_NUM_IMAGES"].values()): + # Find first keyword with zero (unicode issue) + removeme = [] + for keyword in self._STATE["_KEYWORDS"]: + if self._STATE["_NUM_IMAGES"][keyword] == 0: + removeme.append(keyword) + msgs.append( + { + 'level': 'WARNING', + 'message': 'The following keyword(s) do not yield any photos: ' + ', '.join(map('"{0}"'.format, removeme)), + 'link': None + } + ) + return msgs + + def explainState(self): + # override this if you wish to show additional on-screen information for a specific state + # return String + return None + + # All the OAuth functionality: + + def getOAuthScope(self): + # *Override* to define any needed OAuth scope + # must return array of string(s) + return None + + def setOAuthConfig(self, config): + # Provides OAuth config data for linking. + # Without this information, OAuth cannot be done. + # If config is invalid, returns False + self._STATE['_OAUTH_CONFIG'] = config + if self._OAUTH is not None: + self._OAUTH.setOAuth(self._STATE['_OAUTH_CONFIG']) + self.postSetup() + + self.saveState() + return True + + def helpOAuthConfig(self): + return 'Should explain what kind of content to provide' + + def hasOAuthConfig(self): + # Returns true/false if we have a config for oauth + return self._STATE['_OAUTH_CONFIG'] is not None + + def hasOAuth(self): + # Tests if we have a functional OAuth link, + # returns False if we need to set it up + return self._STATE['_OAUTH_CONTEXT'] is not None + + def invalidateOAuth(self): + # Removes previously negotiated OAuth + self._STATE['_OAUTH_CONFIG'] = None + self._STATE['_OAUTH_CONTEXT'] = None + self.saveState() + + def startOAuth(self): + # Returns a HTTP redirect to begin OAuth or None if + # oauth isn't configured. Normally not overriden + return self._OAUTH.initiate() + + def finishOAuth(self, url): + # Called when OAuth sequence has completed + self._OAUTH.complete(url) + self.saveState() + + def _setOAuthToken(self, token): + self._STATE['_OAUTH_CONTEXT'] = token + self.saveState() + + def _getOAuthToken(self): + return self._STATE['_OAUTH_CONTEXT'] + + def migrateOAuthToken(self, token): + if self._STATE['_OAUTH_CONTEXT'] is not None: + logging.error('Cannot migrate token, already have one!') + return + logging.debug('Setting token to %s' % repr(token)) + self._STATE['_OAUTH_CONTEXT'] = token + self.saveState() + + # For services which require static auth: + + def validateConfiguration(self, config): + # Allow service to validate config, if correct, return None + # If incorrect, return helpful error message. + # config is a map with fields and their values + return 'Not overriden yet but config is enabled' + + def setConfiguration(self, config): + # Setup any needed authentication data for this + # service. + self._STATE['_CONFIG'] = config + self.saveState() + + def getConfiguration(self): + return self._STATE['_CONFIG'] + + def hasConfiguration(self): + # Checks if it has auth data + return self._STATE['_CONFIG'] is not None + + def getConfigurationFields(self): + # Returns a key/value map with: + # "field" => [ "type" => "STR/INT", "name" => "Human readable", "description" => "Longer text" ] + # Allowing UX to be dynamically created + # Supported field types are: STR, INT, PW (password means it will obscure it on input) + return {'username': {'type': 'STR', 'name': 'Username', 'description': 'Username to use for login'}} + + # Keyword management: + + def validateKeywords(self, keywords): + # Quick check, don't allow duplicates! + if keywords in self.getKeywords(): + logging.error('Keyword is already in list') + return {'error': 'Keyword already in list', 'keywords': keywords} + + return {'error': None, 'keywords': keywords} + + def addKeywords(self, keywords): + # This is how the user will configure it, this adds a new set of keywords to this + # service module. Return none on success, string with error on failure + keywords = keywords.strip() + + if not self.needKeywords(): + return {'error': 'Doesn\'t use keywords', 'keywords': keywords} + if keywords == '': + return {'error': 'Keyword string cannot be empty', 'kewords': keywords} + + tst = self.validateKeywords(keywords) + if tst['error'] is None: + keywords = tst['keywords'] + self._STATE['_KEYWORDS'].append(keywords) + self.saveState() + return tst + + def getKeywords(self): + # Returns an array of all keywords + return self._STATE['_KEYWORDS'] + + def getKeywordSourceUrl(self, index): + # Override to provide a source link + return None + + def getKeywordDetails(self, index): + # Override so we can tell more + # Format of data is: + # ('short': short, 'long' : ["line1", "line2", ...]) where short is a string and long is a string array + return None + + def hasKeywordDetails(self): + # Override so we can tell more + return False + + def hasKeywordSourceUrl(self): + # Override to provide source url support + return False + + def removeKeywords(self, index): + if index < 0 or index > (len(self._STATE['_KEYWORDS'])-1): + logging.error('removeKeywords: Out of range %d' % index) + return False + kw = self._STATE['_KEYWORDS'].pop(index) + if kw in self._STATE['_NUM_IMAGES']: + del self._STATE['_NUM_IMAGES'][kw] + self.saveState() + # Also kill the memory of this keyword + self.memory.forget(kw) + return True + + def needKeywords(self): + # Some services don't have keywords. Override this to return false + # to remove the keywords options. + return True + + def helpKeywords(self): + return 'Has not been defined' + + def getRandomKeywordIndex(self): + # select keyword index at random but weighted by the number of images of each album + totalImages = self.getImagesTotal() + if totalImages == 0: + return 0 + numImages = [self._STATE['_NUM_IMAGES'][kw] for kw in self._STATE['_NUM_IMAGES']] + return helper.getWeightedRandomIndex(numImages) + + def getKeywordLink(self, index): + if index < 0 or index > (len(self._STATE['_KEYWORDS'])-1): + logging.error('removeKeywords: Out of range %d' % index) + return + + # Extras - Allows easy access to config: + + def getExtras(self): + return self._STATE['_EXTRAS'] + + def setExtras(self, data): + self._STATE['_EXTRAS'] = data + self.saveState() + + # Actual hard work: + + def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize, randomize): + # This call requires the service to download the next item it + # would like to show. The destinationFile has to be used as where to save it + # and you are only allowed to provide content listed in the supportedMimeTypes. + # displaySize holds the keys width & height to provide a hint for the service to avoid downloading HUGE files + # Return for this function is a key/value map with the following MANDATORY + # fields: + # "id" : a unique - preferably not-changing - ID to identify the same image in future requests, + # e.g. hashString(imageUrl) + # "mimetype" : the filetype you downloaded, for example "image/jpeg" + # "error" : None or a human readable text string as to why you failed + # "source" : Link to where the item came from or None if not provided + # + # NOTE! If you need to index anything before you can get the first item, this would + # also be the place to do it. + # + # If your service uses keywords (as albums) 'selectImageFromAlbum' of the baseService class should do most of + # the work for you. You will probably only need to implement 'getImagesFor' and 'addUrlParams' + + if self.needKeywords(): + if len(self.getKeywords()) == 0: + return ImageHolder().setError('No albums have been specified') + + if randomize: + result = self.selectRandomImageFromAlbum(destinationFile, supportedMimeTypes, displaySize) + else: + result = self.selectNextImageFromAlbum(destinationFile, supportedMimeTypes, displaySize) + if result is None: + result = ImageHolder().setError('No (new) images could be found') + else: + result = ImageHolder().setError('prepareNextItem() not implemented') + + return result + + def _getImagesFor(self, keyword): + images = self.getImagesFor(keyword) + if images is None: + logging.warning( + 'Function returned None, this is used sometimes when a temporary error happens. Still logged' + ) + + if images is not None and len(images) > 0: + self._STATE["_NUM_IMAGES"][keyword] = len(images) + # Change next time for refresh (postpone if you will) + self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY + else: + self._STATE["_NUM_IMAGES"][keyword] = 0 + return images + + def getImagesFor(self, keyword): + # You need to override this function if your service needs keywords and + # you want to use 'selectImageFromAlbum' of the baseService class + # This function should collect data about all images matching a specific keyword + # Return for this function is a list of multiple key/value maps each containing the following MANDATORY fields: + # "id": a unique - preferably not-changing - ID to identify the same image in future requests, + # e.g. hashString(imageUrl) + # "url": Link to the actual image file + # "sources": Link to where the item came from or None if not provided + # "mimetype": the filetype of the image, for example "image/jpeg" + # can be None, but you should catch unsupported mimetypes after the image has downloaded + # (example: svc_simpleurl.py) + # "size": a key/value map containing "width" and "height" of the image + # can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams' + # "filename": the original filename of the image or None if unknown (only used for debugging purposes) + # "error": If present, will generate an error shown to the user with the text within this key as the message + + return [ImageHolder().setError('getImagesFor() not implemented')] + + def _clearImagesFor(self, keyword): + self._STATE["_NUM_IMAGES"].pop(keyword, None) + self._STATE['_NEXT_SCAN'].pop(keyword, None) + self.memory.forget(keyword) + self.clearImagesFor(keyword) + + def clearImagesFor(self, keyword): + # You can hook this function to do any additional needed cleanup + # keyword is the item for which you need to clear the images for + pass + + def freshnessImagesFor(self, keyword): + # You need to implement this function if you intend to support refresh of content + # keyword is the item for which you need to clear the images for. Should return age of content in hours + return 0 + + def getContentUrl(self, image, hints): + # Allows a content provider to do extra operations as needed to + # extract the correct URL. + # + # image is an image object + # + # hints is a map holding various hints to be used by the content provider. + # "size" holds "width" and "height" of the ideal image dimensions based on display size + # "display" holds "width" and "height" of the physical display + # + # By default, if you don't override this, it will simply use the image.url as the return + return image.url + + # Following functions are helper functions + + def selectRandomImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize): + # chooses an album and selects an image from that album. Returns an image object or None + # if no images are available. + + keywords = self.getKeywords() + index = self.getRandomKeywordIndex() + + # if current keywordList[index] does not contain any new images --> just run through all albums + for i in range(0, len(keywords)): + self.setIndex(keyword=(index + i) % len(keywords)) + keyword = keywords[self.getIndexKeyword()] + + # a provider-specific implementation for 'getImagesFor' is obligatory! + # We use a wrapper to clear things up + images = self._getImagesFor(keyword) + if images is None or len(images) == 0: + self.setIndex(0) + continue + elif images[0].error is not None: + # Something went wrong, only return first image since it holds the error + return images[0] + self.saveState() + + image = self.selectRandomImage(keyword, images, supportedMimeTypes, displaySize) + if image is None: + self.setIndex(0) + continue + + return self.fetchImage(image, destinationDir, supportedMimeTypes, displaySize) + return None + + def generateFilename(self): + return str(uuid.uuid4()) + + def fetchImage(self, image, destinationDir, supportedMimeTypes, displaySize): + filename = os.path.join(destinationDir, self.generateFilename()) + + if image.cacheAllow: + # Look it up in the cache mgr + if self._CACHEMGR is None: + logging.error('CacheManager is not available') + else: + cacheFile = self._CACHEMGR.getCachedImage(image.getCacheId(), filename) + if cacheFile: + image.setFilename(cacheFile) + image.cacheUsed = True + + if not image.cacheUsed: + recommendedSize = self.calcRecommendedSize(image.dimensions, displaySize) + if recommendedSize is None: + recommendedSize = displaySize + url = self.getContentUrl(image, {'size': recommendedSize, 'display': displaySize}) + if url is None: + return ImageHolder().setError('Unable to download image, no URL') + + try: + result = self.requestUrl(url, destination=filename) + except (RequestResult.RequestExpiredToken, RequestInvalidToken): + logging.exception('Cannot fetch due to token issues') + result = RequestResult().setResult(RequestResult.OAUTH_INVALID) + self._OAUTH = None + except requests.exceptions.RequestException: + logging.exception('request to download image failed') + result = RequestResult().setResult(RequestResult.NO_NETWORK) + + if not result.isSuccess(): + return ImageHolder().setError('%d: Unable to download image!' % result.httpcode) + else: + image.setFilename(filename) + if image.filename is not None: + image.setMimetype(helper.getMimetype(image.filename)) + return image + + def selectNextImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize): + # chooses an album and selects an image from that album. Returns an image object or None + # if no images are available. + + keywordList = self.getKeywords() + keywordCount = len(keywordList) + index = self.getIndexKeyword() + + # if current keywordList[index] does not contain any new images --> just run through all albums + for i in range(0, keywordCount): + if (index + i) >= keywordCount: + # (non-random image order): return if the last album is exceeded. + # serviceManager should use next service + break + self.setIndex(keyword=(index + i) % keywordCount) + keyword = keywordList[self.getIndexKeyword()] + + # a provider-specific implementation for 'getImagesFor' is obligatory! + # We use a wrapper to clear things up + images = self._getImagesFor(keyword) + if images is None or len(images) == 0: + self.setIndex(0) + continue + elif images[0].error is not None: + # Something went wrong, only return first image since it holds the error + return images[0] + self.saveState() + + image = self.selectNextImage(keyword, images, supportedMimeTypes, displaySize) + if image is None: + self.setIndex(0) + continue + + return self.fetchImage(image, destinationDir, supportedMimeTypes, displaySize) + return None + + def selectRandomImage(self, keywords, images, supportedMimeTypes, displaySize): + imageCount = len(images) + index = random.SystemRandom().randint(0, imageCount-1) + + logging.debug('There are %d images total' % imageCount) + for i in range(0, imageCount): + image = images[(index + i) % imageCount] + + orgFilename = image.filename if image.filename is not None else image.id + if self.memory.seen(image.id, keywords): + logging.debug("Skipping already displayed image '%s'!" % orgFilename) + continue + + # No matter what, we need to track that we considered this image + self.memory.remember(image.id, keywords) + + if not self.isCorrectOrientation(image.dimensions, displaySize): + logging.debug("Skipping image '%s' due to wrong orientation!" % orgFilename) + continue + if image.mimetype is not None and image.mimetype not in supportedMimeTypes: + # Make sure we don't get a video, unsupported for now (gif is usually bad too) + logging.debug('Skipping unsupported media: %s' % (image.mimetype)) + continue + + self.setIndex((index + i) % imageCount) + return image + return None + + def selectNextImage(self, keywords, images, supportedMimeTypes, displaySize): + imageCount = len(images) + index = self.getIndexImage() + + for i in range(index, imageCount): + image = images[i] + + orgFilename = image.filename if image.filename is not None else image.id + if self.memory.seen(image.id, keywords): + logging.debug("Skipping already displayed image '%s'!" % orgFilename) + continue + + # No matter what, we need to track that we considered this image + self.memory.remember(image.id, keywords) + + if not self.isCorrectOrientation(image.dimensions, displaySize): + logging.debug("Skipping image '%s' due to wrong orientation!" % orgFilename) + continue + if image.mimetype is not None and image.mimetype not in supportedMimeTypes: + # Make sure we don't get a video, unsupported for now (gif is usually bad too) + logging.debug('Skipping unsupported media: %s' % (image.mimetype)) + continue + + self.setIndex(i) + return image + return None + + def requestUrl(self, url, destination=None, params=None, data=None, usePost=False): + result = RequestResult() + + if self._OAUTH is not None: + # Use OAuth path + try: + result = self._OAUTH.request(url, destination, params, data=data, usePost=usePost) + except (RequestExpiredToken, RequestInvalidToken): + logging.exception('Cannot fetch due to token issues') + result = RequestResult().setResult(RequestResult.OAUTH_INVALID) + self.invalidateOAuth() + except requests.exceptions.RequestException: + logging.exception('request to download image failed') + result = RequestResult().setResult(RequestResult.NO_NETWORK) + else: + tries = 0 + while tries < 5: + try: + if usePost: + r = requests.post(url, params=params, json=data, timeout=180) + else: + r = requests.get(url, params=params, timeout=180) + break + except Exception: + logging.exception('Issues downloading') + time.sleep(tries / 10) # Back off 10, 20, ... depending on tries + tries += 1 + logging.warning('Retrying again, attempt #%d', tries) + + if tries == 5: + logging.error('Failed to download due to network issues') + raise RequestNoNetwork + + if r: + result.setHTTPCode(r.status_code).setHeaders(r.headers).setResult(RequestResult.SUCCESS) + + if destination is None: + result.setContent(r.content) + else: + with open(destination, 'wb') as f: + for chunk in r.iter_content(chunk_size=1024): + f.write(chunk) + result.setFilename(destination) + return result + + def calcRecommendedSize(self, imageSize, displaySize): + # The recommended image size is basically the displaySize extended along one side to match the aspect ratio of + # your image e.g. displaySize: 1920x1080, imageSize: 4000x3000 --> recImageSize: 1920x1440 + # If possible every request url should contain the recommended width/height as parameters to reduce image file + # sizes. + # That way the image provider does most of the scaling (instead of the rather slow raspberryPi), + # the image only needs to be cropped (zoomOnly) or downscaled a little bit (blur / do nothing) during + # post-processing. + + if imageSize is None or "width" not in imageSize or "height" not in imageSize: + return None + + oar = float(imageSize['width'])/float(imageSize['height']) + dar = float(displaySize['width'])/float(displaySize['height']) + + newImageSize = {} + if imageSize['width'] > displaySize['width'] and imageSize['height'] > displaySize['height']: + if oar <= dar: + newImageSize['width'] = displaySize['width'] + newImageSize['height'] = int(float(displaySize['width']) / oar) + else: + newImageSize['width'] = int(float(displaySize['height']) * oar) + newImageSize['height'] = displaySize['height'] + else: + newImageSize['width'] = imageSize['width'] + newImageSize['height'] = imageSize['height'] + + return newImageSize + + def isCorrectOrientation(self, imageSize, displaySize): + if displaySize['force_orientation'] == 0: + return True + if imageSize is None or "width" not in imageSize or "height" not in imageSize: + # always show image if size is unknown! + return True + + # NOTE: square images are being treated as portrait-orientation + image_orientation = 0 if int(imageSize["width"]) > int(imageSize["height"]) else 1 + display_orientation = 0 if displaySize["width"] > displaySize["height"] else 1 + + return image_orientation == display_orientation + + def getStoragePath(self): + return self._DIR_PRIVATE + + def hashString(self, text): + if type(text) is not str: + # make sure it's unicode + a = text.decode('ascii', errors='replace') else: - with open(destination, 'wb') as f: - for chunk in r.iter_content(chunk_size=1024): - f.write(chunk) - result.setFilename(destination) - return result - - def calcRecommendedSize(self, imageSize, displaySize): - # The recommended image size is basically the displaySize extended along one side to match the aspect ratio of your image - # e.g. displaySize: 1920x1080, imageSize: 4000x3000 --> recImageSize: 1920x1440 - # If possible every request url should contain the recommended width/height as parameters to reduce image file sizes. - # That way the image provider does most of the scaling (instead of the rather slow raspberryPi), - # the image only needs to be cropped (zoomOnly) or downscaled a little bit (blur / do nothing) during post-processing. - - if imageSize is None or "width" not in imageSize or "height" not in imageSize: - return None - - oar = float(imageSize['width'])/float(imageSize['height']) - dar = float(displaySize['width'])/float(displaySize['height']) - - newImageSize = {} - if imageSize['width'] > displaySize['width'] and imageSize['height'] > displaySize['height']: - if oar <= dar: - newImageSize['width'] = displaySize['width'] - newImageSize['height'] = int(float(displaySize['width']) / oar) - else: - newImageSize['width'] = int(float(displaySize['height']) * oar) - newImageSize['height'] = displaySize['height'] - else: - newImageSize['width'] = imageSize['width'] - newImageSize['height'] = imageSize['height'] - - return newImageSize - - def isCorrectOrientation(self, imageSize, displaySize): - if displaySize['force_orientation'] == 0: - return True - if imageSize is None or "width" not in imageSize or "height" not in imageSize: - # always show image if size is unknown! - return True - - # NOTE: square images are being treated as portrait-orientation - image_orientation = 0 if int(imageSize["width"]) > int(imageSize["height"]) else 1 - display_orientation = 0 if displaySize["width"] > displaySize["height"] else 1 - - return image_orientation == display_orientation - - def getStoragePath(self): - return self._DIR_PRIVATE - - def hashString(self, text): - if type(text) is not str: - # make sure it's unicode - a = text.decode('ascii', errors='replace') - else: - a = text - a = a.encode('utf-8', errors='replace') - return hashlib.sha1(a).hexdigest() - - def createImageHolder(self): - return ImageHolder() - - def setIndex(self, image = None, keyword = None, addImage = 0, addKeyword = 0): - wrapped = False - if addImage != 0: - self._STATE['_INDEX_IMAGE'] += addImage - elif image is not None: - self._STATE['_INDEX_IMAGE'] = image - if addKeyword != 0: - self._STATE['_INDEX_KEYWORD'] += addKeyword - elif keyword is not None: - self._STATE['_INDEX_KEYWORD'] = keyword - - # Sanity - if self._STATE['_INDEX_KEYWORD'] > len(self._STATE['_KEYWORDS']): - if addKeyword != 0: - self._STATE['_INDEX_KEYWORD'] = 0 # Wraps when adding - wrapped = True - else: - self._STATE['_INDEX_KEYWORD'] = len(self._STATE['_KEYWORDS'])-1 - elif self._STATE['_INDEX_KEYWORD'] < 0: - if addKeyword != 0: - self._STATE['_INDEX_KEYWORD'] = len(self._STATE['_KEYWORDS'])-1 # Wraps when adding - wrapped = True - else: - self._STATE['_INDEX_KEYWORD'] = 0 - return wrapped - - def getIndexImage(self): - return self._STATE['_INDEX_IMAGE'] - - def getIndexKeyword(self): - return self._STATE['_INDEX_KEYWORD'] - - ###[ Slideshow controls ]======================================================= - - def nextAlbum(self): - # skip to the next album - # return False if service is out of albums to tell the serviceManager that it should use the next Service instead - return not self.setIndex(0, addKeyword=1) - - def prevAlbum(self): - # skip to the previous album - # return False if service is already on its first album to tell the serviceManager that it should use the previous Service instead - return not self.setIndex(0, addKeyword=-1) + a = text + a = a.encode('utf-8', errors='replace') + return hashlib.sha1(a).hexdigest() + + def createImageHolder(self): + return ImageHolder() + + def setIndex(self, image=None, keyword=None, addImage=0, addKeyword=0): + wrapped = False + if addImage != 0: + self._STATE['_INDEX_IMAGE'] += addImage + elif image is not None: + self._STATE['_INDEX_IMAGE'] = image + if addKeyword != 0: + self._STATE['_INDEX_KEYWORD'] += addKeyword + elif keyword is not None: + self._STATE['_INDEX_KEYWORD'] = keyword + + # Sanity + if self._STATE['_INDEX_KEYWORD'] > len(self._STATE['_KEYWORDS']): + if addKeyword != 0: + self._STATE['_INDEX_KEYWORD'] = 0 # Wraps when adding + wrapped = True + else: + self._STATE['_INDEX_KEYWORD'] = len(self._STATE['_KEYWORDS'])-1 + elif self._STATE['_INDEX_KEYWORD'] < 0: + if addKeyword != 0: + self._STATE['_INDEX_KEYWORD'] = len(self._STATE['_KEYWORDS'])-1 # Wraps when adding + wrapped = True + else: + self._STATE['_INDEX_KEYWORD'] = 0 + return wrapped + + def getIndexImage(self): + return self._STATE['_INDEX_IMAGE'] + + def getIndexKeyword(self): + return self._STATE['_INDEX_KEYWORD'] + + # [ Slideshow controls ]======================================================= + + def nextAlbum(self): + # skip to the next album + # return False if service is out of albums to tell the serviceManager + # that it should use the next Service instead + return not self.setIndex(0, addKeyword=1) + + def prevAlbum(self): + # skip to the previous album + # return False if service is already on its first album to tell the + # serviceManager that it should use the previous Service instead + return not self.setIndex(0, addKeyword=-1) diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py index 10ffdf9..7742dd6 100755 --- a/services/svc_googlephotos.py +++ b/services/svc_googlephotos.py @@ -22,383 +22,419 @@ from modules.network import RequestResult from modules.helper import helper + class GooglePhotos(BaseService): - SERVICE_NAME = 'GooglePhotos' - SERVICE_ID = 2 - - def __init__(self, configDir, id, name): - BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=True) - - def getOAuthScope(self): - return ['https://www.googleapis.com/auth/photoslibrary.readonly'] - - def helpOAuthConfig(self): - return 'Please upload client.json from the Google API Console' - - def helpKeywords(self): - return 'Currently, each entry represents the name of an album (case-insensitive). If you want the latest photos, simply write "latest" as album' - - def hasKeywordSourceUrl(self): - return True - - def getExtras(self): - # Normalize - result = BaseService.getExtras(self) - if result is None: - return {} - return result - - def postSetup(self): - extras = self.getExtras() - keywords = self.getKeywords() - - if len(extras) == 0 and keywords is not None and len(keywords) > 0: - logging.info('Migrating to new format with preresolved album ids') - for key in keywords: - if key.lower() == 'latest': - continue - albumId = self.translateKeywordToId(key) - if albumId is None: - logging.error('Existing keyword cannot be resolved') + SERVICE_NAME = 'GooglePhotos' + SERVICE_ID = 2 + + def __init__(self, configDir, id, name): + BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=True) + + def getOAuthScope(self): + return ['https://www.googleapis.com/auth/photoslibrary.readonly'] + + def helpOAuthConfig(self): + return 'Please upload client.json from the Google API Console' + + def helpKeywords(self): + return 'Currently, each entry represents the name of an album (case-insensitive). ' \ + 'If you want the latest photos, simply write "latest" as album' + + def hasKeywordSourceUrl(self): + return True + + def getExtras(self): + # Normalize + result = BaseService.getExtras(self) + if result is None: + return {} + return result + + def postSetup(self): + extras = self.getExtras() + keywords = self.getKeywords() + + if len(extras) == 0 and keywords is not None and len(keywords) > 0: + logging.info('Migrating to new format with preresolved album ids') + for key in keywords: + if key.lower() == 'latest': + continue + albumId = self.translateKeywordToId(key) + if albumId is None: + logging.error('Existing keyword cannot be resolved') + else: + extras[key] = albumId + self.setExtras(extras) else: - extras[key] = albumId - self.setExtras(extras) - else: - # Make sure all keywords are LOWER CASE (which is why I wrote it all in upper case :)) - extras_old = self.getExtras() - extras = {} - - for k in extras_old: - kk = k.upper().lower().strip() - if len(extras) > 0 or kk != k: - extras[kk] = extras_old[k] - - if len(extras) > 0: - logging.debug('Had to translate non-lower-case keywords due to bug, should be a one-time thing') - self.setExtras(extras) - - # Sanity, also make sure extras is BLANK if keywords is BLANK - if len(self.getKeywords()) == 0: + # Make sure all keywords are LOWER CASE (which is why I wrote it all in upper case :)) + extras_old = self.getExtras() + extras = {} + + for k in extras_old: + kk = k.upper().lower().strip() + if len(extras) > 0 or kk != k: + extras[kk] = extras_old[k] + + if len(extras) > 0: + logging.debug('Had to translate non-lower-case keywords due to bug, should be a one-time thing') + self.setExtras(extras) + + # Sanity, also make sure extras is BLANK if keywords is BLANK + if len(self.getKeywords()) == 0: + extras = self.getExtras() + if len(extras) > 0: + logging.warning('Mismatch between keywords and extras info, corrected') + self.setExtras({}) + + def getKeywordSourceUrl(self, index): + keys = self.getKeywords() + if index < 0 or index >= len(keys): + return 'Out of range, index = %d' % index + keywords = keys[index] extras = self.getExtras() - if len(extras) > 0: - logging.warning('Mismatch between keywords and extras info, corrected') - self.setExtras({}) - - def getKeywordSourceUrl(self, index): - keys = self.getKeywords() - if index < 0 or index >= len(keys): - return 'Out of range, index = %d' % index - keywords = keys[index] - extras = self.getExtras() - if keywords not in extras: - return 'https://photos.google.com/' - return extras[keywords]['sourceUrl'] - - def getKeywordDetails(self, index): - # Override so we can tell more, for google it means we simply review what we would show - keys = self.getKeywords() - if index < 0 or index >= len(keys): - return 'Out of range, index = %d' % index - keyword = keys[index] - - # This is not going to be fast... - data = self.getImagesFor(keyword, rawReturn=True) - mimes = helper.getSupportedTypes() - memory = self.memory.getList(keyword) - - countv = 0 - counti = 0 - countu = 0 - types = {} - for entry in data: - if entry['mimeType'].startswith('video/'): - countv += 1 - elif entry['mimeType'].startswith('image/'): - if entry['mimeType'].lower() in mimes: - counti += 1 + if keywords not in extras: + return 'https://photos.google.com/' + return extras[keywords]['sourceUrl'] + + def getKeywordDetails(self, index): + # Override so we can tell more, for google it means we simply review what we would show + keys = self.getKeywords() + if index < 0 or index >= len(keys): + return 'Out of range, index = %d' % index + keyword = keys[index] + + # This is not going to be fast... + data = self.getImagesFor(keyword, rawReturn=True) + mimes = helper.getSupportedTypes() + memory = self.memory.getList(keyword) + + countv = 0 + counti = 0 + countu = 0 + types = {} + for entry in data: + if entry['mimeType'].startswith('video/'): + countv += 1 + elif entry['mimeType'].startswith('image/'): + if entry['mimeType'].lower() in mimes: + counti += 1 + else: + countu += 1 + + if entry['mimeType'] in types: + types[entry['mimeType']] += 1 + else: + types[entry['mimeType']] = 1 + + longer = ['Below is a breakdown of the content found in this album'] + unsupported = [] + for i in types: + if i in mimes: + longer.append('%s has %d items' % (i, types[i])) + else: + unsupported.append('%s has %d items' % (i, types[i])) + + extra = '' + if len(unsupported) > 0: + longer.append('') + longer.append('Mime types listed below were also found but are as of yet not supported:') + longer.extend(unsupported) + if countu > 0: + extra = ' where %d is not yet unsupported' % countu + return { + 'short': '%d items fetched from album, %d images%s, %d videos, %d is unknown. %d has been shown' % + ( + len(data), + counti + countu, + extra, + countv, + len(data) - counti - countv, + len(memory) + ), + 'long': longer + } + + def hasKeywordDetails(self): + # Override so we can tell more, for google it means we simply review what we would show + return True + + def removeKeywords(self, index): + # Override since we need to delete our private data + keys = self.getKeywords() + if index < 0 or index >= len(keys): + return + keywords = keys[index].upper().lower().strip() + filename = os.path.join(self.getStoragePath(), self.hashString(keywords) + '.json') + if os.path.exists(filename): + os.unlink(filename) + if BaseService.removeKeywords(self, index): + # Remove any extras + extras = self.getExtras() + if keywords in extras: + del extras[keywords] + self.setExtras(extras) + return True else: - countu += 1 - - if entry['mimeType'] in types: - types[entry['mimeType']] += 1 - else: - types[entry['mimeType']] = 1 - - longer = ['Below is a breakdown of the content found in this album'] - unsupported = [] - for i in types: - if i in mimes: - longer.append('%s has %d items' % (i, types[i])) - else: - unsupported.append('%s has %d items' % (i, types[i])) - - extra = '' - if len(unsupported) > 0: - longer.append('') - longer.append('Mime types listed below were also found but are as of yet not supported:') - longer.extend(unsupported) - if countu > 0: - extra = ' where %d is not yet unsupported' % countu - return { - 'short': '%d items fetched from album, %d images%s, %d videos, %d is unknown. %d has been shown' % (len(data), counti + countu, extra, countv, len(data) - counti - countv, len(memory)), - 'long' : longer - } - - def hasKeywordDetails(self): - # Override so we can tell more, for google it means we simply review what we would show - return True - - def removeKeywords(self, index): - # Override since we need to delete our private data - keys = self.getKeywords() - if index < 0 or index >= len(keys): - return - keywords = keys[index].upper().lower().strip() - filename = os.path.join(self.getStoragePath(), self.hashString(keywords) + '.json') - if os.path.exists(filename): - os.unlink(filename) - if BaseService.removeKeywords(self, index): - # Remove any extras - extras = self.getExtras() - if keywords in extras: - del extras[keywords] - self.setExtras(extras) - return True - else: - return False - - def validateKeywords(self, keywords): - tst = BaseService.validateKeywords(self, keywords) - if tst["error"] is not None: - return tst - - # Remove quotes around keyword - if keywords[0] == '"' and keywords[-1] == '"': - keywords = keywords[1:-1] - keywords = keywords.upper().lower().strip() - - # No error in input, resolve album now and provide it as extra data - albumId = None - if keywords != 'latest': - albumId = self.translateKeywordToId(keywords) - if albumId is None: - return {'error':'No such album "%s"' % keywords, 'keywords' : keywords} - - return {'error':None, 'keywords':keywords, 'extras' : albumId} - - def addKeywords(self, keywords): - result = BaseService.addKeywords(self, keywords) - if result['error'] is None and result['extras'] is not None: - k = result['keywords'] - extras = self.getExtras() - extras[k] = result['extras'] - self.setExtras(extras) - return result - - - def isGooglePhotosEnabled(self): - url = 'https://photoslibrary.googleapis.com/v1/albums' - data = self.requestUrl(url, params={'pageSize':1}) - ''' -{\n "error": {\n "code": 403,\n "message": "Photos Library API has not been used in project 742138104895 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/photoslibrary.googleapis.com/overview?project=742138104895 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",\n "status": "PERMISSION_DENIED",\n "details": [\n {\n "@type": "type.googleapis.com/google.rpc.Help",\n "links": [\n {\n "description": "Google developers console API activation",\n "url": "https://console.developers.google.com/apis/api/photoslibrary.googleapis.com/overview?project=742138104895"\n }\n ]\n }\n ]\n }\n}\n' - ''' - return not (data.httpcode == 403 and 'Enable it by visiting' in data.content) - - def getQueryForKeyword(self, keyword): - result = None - extras = self.getExtras() - if extras is None: - extras = {} - - if keyword == 'latest': - logging.debug('Use latest 1000 images') - result = { - 'pageSize' : 100, # 100 is API max - 'filters': { - 'mediaTypeFilter': { - 'mediaTypes': [ - 'PHOTO' - ] - } + return False + + def validateKeywords(self, keywords): + tst = BaseService.validateKeywords(self, keywords) + if tst["error"] is not None: + return tst + + # Remove quotes around keyword + if keywords[0] == '"' and keywords[-1] == '"': + keywords = keywords[1:-1] + keywords = keywords.upper().lower().strip() + + # No error in input, resolve album now and provide it as extra data + albumId = None + if keywords != 'latest': + albumId = self.translateKeywordToId(keywords) + if albumId is None: + return {'error': 'No such album "%s"' % keywords, 'keywords': keywords} + + return {'error': None, 'keywords': keywords, 'extras': albumId} + + def addKeywords(self, keywords): + result = BaseService.addKeywords(self, keywords) + if result['error'] is None and result['extras'] is not None: + k = result['keywords'] + extras = self.getExtras() + extras[k] = result['extras'] + self.setExtras(extras) + return result + + def isGooglePhotosEnabled(self): + url = 'https://photoslibrary.googleapis.com/v1/albums' + data = self.requestUrl(url, params={'pageSize': 1}) + ''' The error we see from google in this case: + { + "error": { + "code": 403, + "message": "Photos Library API has not been used in project 742138104895 before or it is disabled. + Enable it by visiting https://console.developers.google.com/someurl then retry. + If you enabled this API recently, wait a few minutes for the action to propagate + to our systems and retry.", + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.Help", + "links": [ + { + "description": "Google developers console API activation", + "url": "https://console.developers.google.com/someurl" + } + ] + } + ] + } } - } - elif keyword in extras: - result = { - 'pageSize' : 100, # 100 is API max - 'albumId' : extras[keyword]['albumId'] - } - return result - - def translateKeywordToId(self, keyword): - albumid = None - source = None - albumname = None - - if keyword == '': - logging.error('Cannot use blank album name') - return None - - if keyword == 'latest': - return None - - logging.debug('Query Google Photos for album named "%s"', keyword) - url = 'https://photoslibrary.googleapis.com/v1/albums' - params={'pageSize':50} #50 is api max - while True: - data = self.requestUrl(url, params=params) - if not data.isSuccess(): - return None - data = json.loads(data.content) - for i in range(len(data['albums'])): - if 'title' in data['albums'][i]: - logging.debug('Album: %s' % data['albums'][i]['title']) - if 'title' in data['albums'][i] and data['albums'][i]['title'].upper().lower().strip() == keyword: - logging.debug('Found album: ' + repr(data['albums'][i])) - albumname = data['albums'][i]['title'] - albumid = data['albums'][i]['id'] - source = data['albums'][i]['productUrl'] - break - if albumid is None and 'nextPageToken' in data: - logging.debug('Another page of albums available') - params['pageToken'] = data['nextPageToken'] - continue - break - - if albumid is None: - url = 'https://photoslibrary.googleapis.com/v1/sharedAlbums' - params = {'pageSize':50}#50 is api max - while True: - data = self.requestUrl(url, params=params) - if not data.isSuccess(): - return None - data = json.loads(data.content) - if 'sharedAlbums' not in data: - logging.debug('User has no shared albums') - break - for i in range(len(data['sharedAlbums'])): - if 'title' in data['sharedAlbums'][i]: - logging.debug('Shared Album: %s' % data['sharedAlbums'][i]['title']) - if 'title' in data['sharedAlbums'][i] and data['sharedAlbums'][i]['title'].upper().lower().strip() == keyword: - albumname = data['sharedAlbums'][i]['title'] - albumid = data['sharedAlbums'][i]['id'] - source = data['sharedAlbums'][i]['productUrl'] + ''' + return not (data.httpcode == 403 and 'Enable it by visiting' in data.content) + + def getQueryForKeyword(self, keyword): + result = None + extras = self.getExtras() + if extras is None: + extras = {} + + if keyword == 'latest': + logging.debug('Use latest 1000 images') + result = { + 'pageSize': 100, # 100 is API max + 'filters': { + 'mediaTypeFilter': { + 'mediaTypes': [ + 'PHOTO' + ] + } + } + } + elif keyword in extras: + result = { + 'pageSize': 100, # 100 is API max + 'albumId': extras[keyword]['albumId'] + } + return result + + def translateKeywordToId(self, keyword): + albumid = None + source = None + albumname = None + + if keyword == '': + logging.error('Cannot use blank album name') + return None + + if keyword == 'latest': + return None + + logging.debug('Query Google Photos for album named "%s"', keyword) + url = 'https://photoslibrary.googleapis.com/v1/albums' + params = {'pageSize': 50} # 50 is api max + while True: + data = self.requestUrl(url, params=params) + if not data.isSuccess(): + return None + data = json.loads(data.content) + for i in range(len(data['albums'])): + if 'title' in data['albums'][i]: + logging.debug('Album: %s' % data['albums'][i]['title']) + if 'title' in data['albums'][i] and data['albums'][i]['title'].upper().lower().strip() == keyword: + logging.debug('Found album: ' + repr(data['albums'][i])) + albumname = data['albums'][i]['title'] + albumid = data['albums'][i]['id'] + source = data['albums'][i]['productUrl'] + break + if albumid is None and 'nextPageToken' in data: + logging.debug('Another page of albums available') + params['pageToken'] = data['nextPageToken'] + continue break - if albumid is None and 'nextPageToken' in data: - logging.debug('Another page of shared albums available') - params['pageToken'] = data['nextPageToken'] - continue - break - - if albumid is None: - return None - return {'albumId': albumid, 'sourceUrl' : source, 'albumName' : albumname} - - def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize): - result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize) - if result is not None: - return result - - if not self.isGooglePhotosEnabled(): - return BaseService.createImageHolder(self).setError('"Photos Library API" is not enabled on\nhttps://console.developers.google.com\n\nCheck the Photoframe Wiki for details') - else: - return BaseService.createImageHolder(self).setError('No (new) images could be found.\nCheck spelling or make sure you have added albums') - - def freshnessImagesFor(self, keyword): - filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') - if not os.path.exists(filename): - return 0 # Superfresh - # Hours should be returned - return (time.time() - os.stat(filename).st_mtime) / 3600 - - def clearImagesFor(self, keyword): - filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') - if os.path.exists(filename): - logging.info('Cleared image information for %s' % keyword) - os.unlink(filename) - - def getImagesFor(self, keyword, rawReturn=False): - filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') - result = [] - if not os.path.exists(filename): - # First time, translate keyword into albumid - params = self.getQueryForKeyword(keyword) - if params is None: - logging.error('Unable to create query the keyword "%s"', keyword) - return [BaseService.createImageHolder(self).setError('Unable to get photos using keyword "%s"' % keyword)] - - url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search' - maxItems = 1000 # Should be configurable - - while len(result) < maxItems: - data = self.requestUrl(url, data=params, usePost=True) - if not data.isSuccess(): - logging.warning('Requesting photo failed with status code %d', data.httpcode) - logging.warning('More details: ' + repr(data.content)) - break + + if albumid is None: + url = 'https://photoslibrary.googleapis.com/v1/sharedAlbums' + params = {'pageSize': 50} # 50 is api max + while True: + data = self.requestUrl(url, params=params) + if not data.isSuccess(): + return None + data = json.loads(data.content) + if 'sharedAlbums' not in data: + logging.debug('User has no shared albums') + break + for i in range(len(data['sharedAlbums'])): + if 'title' in data['sharedAlbums'][i]: + logging.debug('Shared Album: %s' % data['sharedAlbums'][i]['title']) + if 'title' in data['sharedAlbums'][i] \ + and data['sharedAlbums'][i]['title'].upper().lower().strip() == keyword: + albumname = data['sharedAlbums'][i]['title'] + albumid = data['sharedAlbums'][i]['id'] + source = data['sharedAlbums'][i]['productUrl'] + break + if albumid is None and 'nextPageToken' in data: + logging.debug('Another page of shared albums available') + params['pageToken'] = data['nextPageToken'] + continue + break + + if albumid is None: + return None + return {'albumId': albumid, 'sourceUrl': source, 'albumName': albumname} + + def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize): + result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize) + if result is not None: + return result + + if not self.isGooglePhotosEnabled(): + return BaseService.createImageHolder(self) \ + .setError('"Photos Library API" is not enabled on\nhttps://console.developers.google.com\n\n' + 'Check the Photoframe Wiki for details') else: - data = json.loads(data.content) - if 'mediaItems' not in data: - break - logging.debug('Got %d entries, adding it to existing %d entries', len(data['mediaItems']), len(result)) - result += data['mediaItems'] - if 'nextPageToken' not in data: - break - params['pageToken'] = data['nextPageToken'] - logging.debug('Fetching another result-set for this keyword') - - if len(result) > 0: - with open(filename, 'w') as f: - json.dump(result, f) - else: - logging.error('No result returned for keyword "%s"!', keyword) - return [] - - # Now try loading - if os.path.exists(filename): - try: - with open(filename, 'r') as f: - albumdata = json.load(f) - except: - logging.exception('Failed to decode JSON file, maybe it was corrupted? Size is %d', os.path.getsize(filename)) - logging.error('Since file is corrupt, we try to save a copy for later analysis (%s.corrupt)', filename) - try: - if os.path.exists(filename + '.corrupt'): - os.unlink(filename + '.corrupt') - os.rename(filename, filename + '.corrupt') - except: - logging.exception('Failed to save copy of corrupt file, deleting instead') - os.unlink(filename) - albumdata = None - if rawReturn: - return albumdata - return self.parseAlbumInfo(albumdata, keyword) - - def parseAlbumInfo(self, data, keyword): - # parse GooglePhoto specific keys into a format that the base service can understand - if data is None: - return None - parsedImages = [] - for entry in data: - item = BaseService.createImageHolder(self) - item.setId(entry['id']) - item.setSource(entry['productUrl']).setMimetype(entry['mimeType']) - item.setDimensions(entry['mediaMetadata']['width'], entry['mediaMetadata']['height']) - item.allowCache(True) - item.setContentProvider(self) - item.setContentSource(keyword) - parsedImages.append(item) - return parsedImages - - def getContentUrl(self, image, hints): - # Tricky, we need to obtain the real URL before doing anything - data = self.requestUrl('https://photoslibrary.googleapis.com/v1/mediaItems/%s' % image.id) - if data.result != RequestResult.SUCCESS: - logging.error('%d,%d: Failed to get URL', data.httpcode, data.result) - return None - - data = json.loads(data.content) - if 'baseUrl' not in data: - logging.error('Data from Google didn\'t contain baseUrl, see original content:') - logging.error(repr(data)) - return None - return data['baseUrl'] + "=w" + str(hints['size']["width"]) + "-h" + str(hints['size']["height"]) + return BaseService.createImageHolder(self).setError('No (new) images could be found.\n' + 'Check spelling or make sure you have added albums') + + def freshnessImagesFor(self, keyword): + filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') + if not os.path.exists(filename): + return 0 # Superfresh + # Hours should be returned + return (time.time() - os.stat(filename).st_mtime) / 3600 + + def clearImagesFor(self, keyword): + filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') + if os.path.exists(filename): + logging.info('Cleared image information for %s' % keyword) + os.unlink(filename) + + def getImagesFor(self, keyword, rawReturn=False): + filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') + result = [] + if not os.path.exists(filename): + # First time, translate keyword into albumid + params = self.getQueryForKeyword(keyword) + if params is None: + logging.error('Unable to create query the keyword "%s"', keyword) + return [BaseService.createImageHolder(self) + .setError('Unable to get photos using keyword "%s"' % keyword)] + + url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search' + maxItems = 1000 # Should be configurable + + while len(result) < maxItems: + data = self.requestUrl(url, data=params, usePost=True) + if not data.isSuccess(): + logging.warning('Requesting photo failed with status code %d', data.httpcode) + logging.warning('More details: ' + repr(data.content)) + break + else: + data = json.loads(data.content) + if 'mediaItems' not in data: + break + logging.debug('Got %d entries, adding it to existing %d entries', + len(data['mediaItems']), len(result)) + result += data['mediaItems'] + if 'nextPageToken' not in data: + break + params['pageToken'] = data['nextPageToken'] + logging.debug('Fetching another result-set for this keyword') + + if len(result) > 0: + with open(filename, 'w') as f: + json.dump(result, f) + else: + logging.error('No result returned for keyword "%s"!', keyword) + return [] + + # Now try loading + if os.path.exists(filename): + try: + with open(filename, 'r') as f: + albumdata = json.load(f) + except Exception: + logging.exception('Failed to decode JSON file, maybe it was corrupted? Size is %d', + os.path.getsize(filename)) + logging.error('Since file is corrupt, we try to save a copy for later analysis (%s.corrupt)', filename) + try: + if os.path.exists(filename + '.corrupt'): + os.unlink(filename + '.corrupt') + os.rename(filename, filename + '.corrupt') + except Exception: + logging.exception('Failed to save copy of corrupt file, deleting instead') + os.unlink(filename) + albumdata = None + if rawReturn: + return albumdata + return self.parseAlbumInfo(albumdata, keyword) + + def parseAlbumInfo(self, data, keyword): + # parse GooglePhoto specific keys into a format that the base service can understand + if data is None: + return None + parsedImages = [] + for entry in data: + item = BaseService.createImageHolder(self) + item.setId(entry['id']) + item.setSource(entry['productUrl']).setMimetype(entry['mimeType']) + item.setDimensions(entry['mediaMetadata']['width'], entry['mediaMetadata']['height']) + item.allowCache(True) + item.setContentProvider(self) + item.setContentSource(keyword) + parsedImages.append(item) + return parsedImages + + def getContentUrl(self, image, hints): + # Tricky, we need to obtain the real URL before doing anything + data = self.requestUrl('https://photoslibrary.googleapis.com/v1/mediaItems/%s' % image.id) + if data.result != RequestResult.SUCCESS: + logging.error('%d,%d: Failed to get URL', data.httpcode, data.result) + return None + + data = json.loads(data.content) + if 'baseUrl' not in data: + logging.error('Data from Google didn\'t contain baseUrl, see original content:') + logging.error(repr(data)) + return None + return data['baseUrl'] + "=w" + str(hints['size']["width"]) + "-h" + str(hints['size']["height"]) diff --git a/services/svc_picasaweb.py b/services/svc_picasaweb.py index 9da5b19..a966aa0 100644 --- a/services/svc_picasaweb.py +++ b/services/svc_picasaweb.py @@ -19,140 +19,143 @@ import json import logging + class PicasaWeb(BaseService): - SERVICE_NAME = 'PicasaWeb' - SERVICE_ID = 1 - SERVICE_DEPRECATED = True - - def __init__(self, configDir, id, name): - BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=True) - - def getOAuthScope(self): - return ['https://www.googleapis.com/auth/photos'] - - def helpOAuthConfig(self): - return 'Please upload client.json from the Google API Console' - - def helpKeywords(self): - return 'Name of people, location, colors, depiction, pretty much anything that Google Photo search accepts' - - def getMessages(self): - msgs = BaseService.getMessages(self) - msgs.append( - { - 'level': 'ERROR', - 'message' : 'This provider is no longer supported by Google. Please use GooglePhotos. For more details, see photoframe wiki', - 'link': 'https://github.com/mrworf/photoframe/wiki/PicasaWeb-API-ceases-to-work-January-1st,-2019' - } - ) - return msgs - - def hasKeywordSourceUrl(self): - return True - - def getKeywordSourceUrl(self, index): - keys = self.getKeywords() - if index < 0 or index >= len(keys): - return 'Out of range, index = %d' % index - keywords = keys[index] - return 'https://photos.google.com/search/' + keywords - - def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize): - result = self.fetchImage(destinationFile, supportedMimeTypes, displaySize) - if result['error'] is not None: - # If we end up here, two things can have happened - # 1. All images have been shown - # 2. No image or data was able to download - # Try forgetting all data and do another run - self.memoryForget() - for file in os.listdir(self.getStoragePath()): - os.unlink(os.path.join(self.getStoragePath(), file)) - result = self.fetchImage(destinationFile, supportedMimeTypes, displaySize) - return result - - def fetchImage(self, destinationFile, supportedMimeTypes, displaySize): - # First, pick which keyword to use - keywordList = list(self.getKeywords()) - offset = 0 - - # Make sure we always have a default - if len(keywordList) == 0: - keywordList.append('') - else: - offset = self.getRandomKeywordIndex() - - total = len(keywordList) - for i in range(0, total): - index = (i + offset) % total - keyword = keywordList[index] - images = self.getImagesFor(keyword) - if images is None: - continue - - mimeType, imageUrl = self.getUrlFromImages(supportedMimeTypes, displaySize['width'], images, displaySize) - if imageUrl is None: - continue - result = self.requestUrl(imageUrl, destination=destinationFile) - if result['status'] == 200: - return {'mimetype' : mimeType, 'error' : None, 'source':None} - return {'mimetype' : None, 'error' : 'Could not download images from Google Photos', 'source':None} - - def getUrlFromImages(self, types, width, images, displaySize): - # Next, pick an image - count = len(images['feed']['entry']) - offset = random.SystemRandom().randint(0,count-1) - for i in range(0, count): - index = (i + offset) % count - proposed = images['feed']['entry'][index]['content']['src'] - if self.memorySeen(proposed): - continue - self.memoryRemember(proposed) - - if not self.isCorrectOrientation(images[index]['mediaMetadata'], displaySize): - logging.debug("Skipping image '%s' due to wrong orientation!" % images[index]['filename']) - continue - - entry = images['feed']['entry'][index] - # Make sure we don't get a video, unsupported for now (gif is usually bad too) - if entry['content']['type'] in types and 'gphoto$videostatus' not in entry: - return entry['content']['type'], entry['content']['src'].replace('/s1600/', '/s%d/' % width, 1) - elif 'gphoto$videostatus' in entry: - logging.debug('Image is thumbnail for videofile') - else: - logging.warning('Unsupported media: %s (video = %s)' % (entry['content']['type'], repr('gphoto$videostatus' in entry))) - entry = None - return None, None - - def getImagesFor(self, keyword): - return None - - images = None - filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') - if not os.path.exists(filename): - # Request albums - # Picasa limits all results to the first 1000, so get them - params = { - 'kind' : 'photo', - 'start-index' : 1, - 'max-results' : 1000, - 'alt' : 'json', - 'access' : 'all', - 'imgmax' : '1600u', # We will replace this with width of framebuffer in pick_image - # This is where we get cute, we pick from a list of keywords - 'fields' : 'entry(title,content,gphoto:timestamp,gphoto:videostatus)', # No unnecessary stuff - 'q' : keyword - } - url = 'https://picasaweb.google.com/data/feed/api/user/default' - data = self.requestUrl(url, params=params) - if not data.isSuccess(): - logging.warning('Requesting photo failed with status code %d', data.httpcode) - else: - with open(filename, 'w') as f: - f.write(data.content) - - # Now try loading - if os.path.exists(filename): - with open(filename, 'r') as f: - images = json.load(f) - print((repr(images))) - return images + SERVICE_NAME = 'PicasaWeb' + SERVICE_ID = 1 + SERVICE_DEPRECATED = True + + def __init__(self, configDir, id, name): + BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=True) + + def getOAuthScope(self): + return ['https://www.googleapis.com/auth/photos'] + + def helpOAuthConfig(self): + return 'Please upload client.json from the Google API Console' + + def helpKeywords(self): + return 'Name of people, location, colors, depiction, pretty much anything that Google Photo search accepts' + + def getMessages(self): + msgs = BaseService.getMessages(self) + msgs.append( + { + 'level': 'ERROR', + 'message': 'This provider is no longer supported by Google. Please use GooglePhotos. ' + 'For more details, see photoframe wiki', + 'link': 'https://github.com/mrworf/photoframe/wiki/PicasaWeb-API-ceases-to-work-January-1st,-2019' + } + ) + return msgs + + def hasKeywordSourceUrl(self): + return True + + def getKeywordSourceUrl(self, index): + keys = self.getKeywords() + if index < 0 or index >= len(keys): + return 'Out of range, index = %d' % index + keywords = keys[index] + return 'https://photos.google.com/search/' + keywords + + def prepareNextItem(self, destinationFile, supportedMimeTypes, displaySize): + result = self.fetchImage(destinationFile, supportedMimeTypes, displaySize) + if result['error'] is not None: + # If we end up here, two things can have happened + # 1. All images have been shown + # 2. No image or data was able to download + # Try forgetting all data and do another run + self.memoryForget() + for file in os.listdir(self.getStoragePath()): + os.unlink(os.path.join(self.getStoragePath(), file)) + result = self.fetchImage(destinationFile, supportedMimeTypes, displaySize) + return result + + def fetchImage(self, destinationFile, supportedMimeTypes, displaySize): + # First, pick which keyword to use + keywordList = list(self.getKeywords()) + offset = 0 + + # Make sure we always have a default + if len(keywordList) == 0: + keywordList.append('') + else: + offset = self.getRandomKeywordIndex() + + total = len(keywordList) + for i in range(0, total): + index = (i + offset) % total + keyword = keywordList[index] + images = self.getImagesFor(keyword) + if images is None: + continue + + mimeType, imageUrl = self.getUrlFromImages(supportedMimeTypes, displaySize['width'], images, displaySize) + if imageUrl is None: + continue + result = self.requestUrl(imageUrl, destination=destinationFile) + if result['status'] == 200: + return {'mimetype': mimeType, 'error': None, 'source': None} + return {'mimetype': None, 'error': 'Could not download images from Google Photos', 'source': None} + + def getUrlFromImages(self, types, width, images, displaySize): + # Next, pick an image + count = len(images['feed']['entry']) + offset = random.SystemRandom().randint(0, count-1) + for i in range(0, count): + index = (i + offset) % count + proposed = images['feed']['entry'][index]['content']['src'] + if self.memorySeen(proposed): + continue + self.memoryRemember(proposed) + + if not self.isCorrectOrientation(images[index]['mediaMetadata'], displaySize): + logging.debug("Skipping image '%s' due to wrong orientation!" % images[index]['filename']) + continue + + entry = images['feed']['entry'][index] + # Make sure we don't get a video, unsupported for now (gif is usually bad too) + if entry['content']['type'] in types and 'gphoto$videostatus' not in entry: + return entry['content']['type'], entry['content']['src'].replace('/s1600/', '/s%d/' % width, 1) + elif 'gphoto$videostatus' in entry: + logging.debug('Image is thumbnail for videofile') + else: + logging.warning('Unsupported media: %s (video = %s)' % + (entry['content']['type'], repr('gphoto$videostatus' in entry))) + entry = None + return None, None + + def getImagesFor(self, keyword): + return None + + images = None + filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json') + if not os.path.exists(filename): + # Request albums + # Picasa limits all results to the first 1000, so get them + params = { + 'kind': 'photo', + 'start-index': 1, + 'max-results': 1000, + 'alt': 'json', + 'access': 'all', + 'imgmax': '1600u', # We will replace this with width of framebuffer in pick_image + # This is where we get cute, we pick from a list of keywords + 'fields': 'entry(title,content,gphoto:timestamp,gphoto:videostatus)', # No unnecessary stuff + 'q': keyword + } + url = 'https://picasaweb.google.com/data/feed/api/user/default' + data = self.requestUrl(url, params=params) + if not data.isSuccess(): + logging.warning('Requesting photo failed with status code %d', data.httpcode) + else: + with open(filename, 'w') as f: + f.write(data.content) + + # Now try loading + if os.path.exists(filename): + with open(filename, 'r') as f: + images = json.load(f) + print((repr(images))) + return images diff --git a/services/svc_simpleurl.py b/services/svc_simpleurl.py index 2ac4770..f68b0b0 100755 --- a/services/svc_simpleurl.py +++ b/services/svc_simpleurl.py @@ -18,91 +18,98 @@ from modules.helper import helper + class SimpleUrl(BaseService): - SERVICE_NAME = 'Simple URL' - SERVICE_ID = 3 - - def __init__(self, configDir, id, name): - BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=False) - - self.brokenUrls = [] - - def helpKeywords(self): - return 'Each item is a URL that should return a single image. The URL may contain the terms "{width}" and/or "{height}" which will be replaced by numbers describing the size of the display.' - - def removeKeywords(self, index): - url = self.getKeywords()[index] - result = BaseService.removeKeywords(self, index) - if result and url in self.brokenUrls: - self.brokenUrls.remove(url) - return result - - def hasKeywordSourceUrl(self): - return True - - def getKeywordSourceUrl(self, index): - keys = self.getKeywords() - if index < 0 or index >= len(keys): - return 'Out of range, index = %d' % index - return keys[index] - - def validateKeywords(self, keywords): - # Catches most invalid URLs - if not helper.isValidUrl(keywords): - return {'error': 'URL appears to be invalid', 'keywords': keywords} - - return BaseService.validateKeywords(self, keywords) - - def memoryForget(self, keywords=None, forgetHistory=False): - # give broken URLs another try (server may have been temporarily unavailable) - self.brokenUrls = [] - return BaseService.memoryForget(self, keywords=keywords, forgetHistory=forgetHistory) - - def getUrlFilename(self, url): - return url.rsplit("/", 1)[-1] - - def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize, retry=1): - result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize) - if result is None: - return None - # catch broken urls - if result.error is not None and result.source is not None: - logging.warning("broken url detected. You should remove '.../%s' from keywords" % (self.getUrlFilename(result.source))) - # catch unsupported mimetypes (can only be done after downloading the image) - elif result.error is None and result.mimetype not in supportedMimeTypes: - logging.warning("unsupported mimetype '%s'. You should remove '.../%s' from keywords" % (result.mimetype, self.getUrlFilename(result.source))) - else: - return result - - # track broken urls / unsupported mimetypes and display warning message on web interface - self.brokenUrls.append(result.source) - # retry (with another image) - if retry > 0: - return self.selectImageFromAlbum(destinationDir, supportedMimeTypes, displaySize, randomize, retry=retry-1) - return BaseService.createImageHolder(self).setError('%s uses broken urls / unsupported images!' % self.SERVICE_NAME) - - def getImagesFor(self, keyword): - url = keyword - if url in self.brokenUrls: - return [] - image = BaseService.createImageHolder(self).setId(self.hashString(url)).setUrl(url).setSource(url).allowCache(True) - return [image] - - def getContentUrl(self, image, hints): - url = image.url - url = url.replace('{width}', str(hints['size']['width'])) - url = url.replace('{height}', str(hints['size']['height'])) - return url - - # Treat the entire service as one album - # That way you can group images by creating multiple Simple Url Services - def nextAlbum(self): - # Tell the serviceManager to use next service instead - return False - - def prevAlbum(self): - # Tell the serviceManager to use previous service instead - return False - - def resetToLastAlbum(self): - self.resetIndices() + SERVICE_NAME = 'Simple URL' + SERVICE_ID = 3 + + def __init__(self, configDir, id, name): + BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=False) + + self.brokenUrls = [] + + def helpKeywords(self): + return 'Each item is a URL that should return a single image. ' \ + 'The URL may contain the terms "{width}" and/or "{height}" which will be ' \ + 'replaced by numbers describing the size of the display.' + + def removeKeywords(self, index): + url = self.getKeywords()[index] + result = BaseService.removeKeywords(self, index) + if result and url in self.brokenUrls: + self.brokenUrls.remove(url) + return result + + def hasKeywordSourceUrl(self): + return True + + def getKeywordSourceUrl(self, index): + keys = self.getKeywords() + if index < 0 or index >= len(keys): + return 'Out of range, index = %d' % index + return keys[index] + + def validateKeywords(self, keywords): + # Catches most invalid URLs + if not helper.isValidUrl(keywords): + return {'error': 'URL appears to be invalid', 'keywords': keywords} + + return BaseService.validateKeywords(self, keywords) + + def memoryForget(self, keywords=None, forgetHistory=False): + # give broken URLs another try (server may have been temporarily unavailable) + self.brokenUrls = [] + return BaseService.memoryForget(self, keywords=keywords, forgetHistory=forgetHistory) + + def getUrlFilename(self, url): + return url.rsplit("/", 1)[-1] + + def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize, retry=1): + result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize) + if result is None: + return None + # catch broken urls + if result.error is not None and result.source is not None: + logging.warning("broken url detected. You should remove '.../%s' from keywords" % + (self.getUrlFilename(result.source))) + # catch unsupported mimetypes (can only be done after downloading the image) + elif result.error is None and result.mimetype not in supportedMimeTypes: + logging.warning("unsupported mimetype '%s'. You should remove '.../%s' from keywords" % + (result.mimetype, self.getUrlFilename(result.source))) + else: + return result + + # track broken urls / unsupported mimetypes and display warning message on web interface + self.brokenUrls.append(result.source) + # retry (with another image) + if retry > 0: + return self.selectImageFromAlbum(destinationDir, supportedMimeTypes, displaySize, randomize, retry=retry-1) + return BaseService.createImageHolder(self) \ + .setError(f'{self.SERVICE_NAME} uses broken urls / unsupported images!') + + def getImagesFor(self, keyword): + url = keyword + if url in self.brokenUrls: + return [] + image = BaseService.createImageHolder(self).setId( + self.hashString(url)).setUrl(url).setSource(url).allowCache(True) + return [image] + + def getContentUrl(self, image, hints): + url = image.url + url = url.replace('{width}', str(hints['size']['width'])) + url = url.replace('{height}', str(hints['size']['height'])) + return url + + # Treat the entire service as one album + # That way you can group images by creating multiple Simple Url Services + def nextAlbum(self): + # Tell the serviceManager to use next service instead + return False + + def prevAlbum(self): + # Tell the serviceManager to use previous service instead + return False + + def resetToLastAlbum(self): + self.resetIndices() diff --git a/services/svc_usb.py b/services/svc_usb.py index b392124..8581804 100755 --- a/services/svc_usb.py +++ b/services/svc_usb.py @@ -23,329 +23,353 @@ from modules.helper import helper from modules.network import RequestResult -class USB_Photos(BaseService): - SERVICE_NAME = 'USB-Photos' - SERVICE_ID = 4 - - SUPPORTED_FILESYSTEMS = ['exfat', 'vfat', 'ntfs', 'ext2', 'ext3', 'ext4'] - SUBSTATE_NOT_CONNECTED = 404 - - INDEX = 0 - - class StorageUnit: - def __init__(self): - self.device = None - self.uuid = None - self.size = 0 - self.fs = None - self.hotplug = False - self.mountpoint = None - self.freshness = 0 - self.label = None - - def setLabel(self, label): - self.label = label - return self - - def setDevice(self, device): - self.device = device - return self - - def setUUID(self, uuid): - self.uuid = uuid - return self - - def setSize(self, size): - self.size = int(size) - return self - - def setFilesystem(self, fs): - self.fs = fs - return self - - def setHotplug(self, hotplug): - self.hotplug = hotplug - return self - - def setMountpoint(self, mountpoint): - self.mountpoint = mountpoint - return self - - def setFreshness(self, freshness): - self.freshness = int(freshness) - return self - - def getName(self): - if self.label is None: - return self.device - return self.label - - def __init__(self, configDir, id, name): - BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=False) - - def preSetup(self): - USB_Photos.INDEX += 1 - self.usbDir = "/mnt/usb%d" % USB_Photos.INDEX - self.baseDir = os.path.join(self.usbDir, "photoframe") - - self.device = None - if not os.path.exists(self.baseDir): - self.mountStorageDevice() - elif len(os.listdir(self.baseDir)) == 0: - self.unmountBaseDir() - self.mountStorageDevice() - else: - self.checkForInvalidKeywords() - for device in self.detectAllStorageDevices(onlyMounted=True): - if device.mountpoint == self.usbDir: - self.device = device - logging.info("USB-Service has detected device '%s'" % self.device.device) - break - if self.device is None: - # Service should still be working fine - logging.warning("Unable to determine which storage device is mounted to '%s'" % self.usbDir) - - def helpKeywords(self): - return "Place photo albums in /photoframe/{album_name} on your usb-device.\nUse the {album_name} as keyword (CasE-seNsitiVe!).\nIf you want to display all albums simply write 'ALLALBUMS' as keyword.\nAlternatively, place images directly inside the '/photoframe/' directory. " - - def validateKeywords(self, keyword): - if keyword != 'ALLALBUMS' and keyword != '_PHOTOFRAME_': - if keyword not in self.getAllAlbumNames(): - return {'error': 'No such album "%s"' % keyword, 'keywords': keyword} - - return BaseService.validateKeywords(self, keyword) - - def getKeywords(self): - if not os.path.exists(self.baseDir): - return [] - - keywords = list(self._STATE['_KEYWORDS']) - if "ALLALBUMS" in keywords: - # No, you can't have an album called /photoframe/ALLALBUMS ... - keywords.remove("ALLALBUMS") - albums = self.getAllAlbumNames() - keywords.extend([a for a in albums if a not in keywords]) - - if "ALLALBUMS" in albums: - logging.error("You should not have a album called 'ALLALBUMS'!") - - if len(keywords) == 0 and len(self.getBaseDirImages()) != 0 and "_PHOTOFRAME_" not in keywords: - keywords.append("_PHOTOFRAME_") - # _PHOTOFRAME_ can be manually deleted via web interface if other keywords are specified! - - self._STATE['_KEYWORDS'] = keywords - self.saveState() - return keywords - - def checkForInvalidKeywords(self): - index = len(self._STATE['_KEYWORDS'])-1 - for keyword in reversed(self._STATE['_KEYWORDS']): - if keyword == "_PHOTOFRAME_": - if len(self.getBaseDirImages()) == 0: - logging.debug("USB-Service: removing keyword '%s' because there are no images directly inside the basedir!" % keyword) - self.removeKeywords(index) - elif keyword not in self.getAllAlbumNames(): - logging.info("USB-Service: removing invalid keyword: %s" % keyword) - self.removeKeywords(index) - index -= 1 - self.saveState() - - def updateState(self): - self.subState = None - if not os.path.exists(self.baseDir): - if not self.mountStorageDevice(): - self._CURRENT_STATE = BaseService.STATE_NO_IMAGES - self.subState = USB_Photos.SUBSTATE_NOT_CONNECTED - return self._CURRENT_STATE - if len(self.getAllAlbumNames()) == 0 and len(self.getBaseDirImages()) == 0: - self._CURRENT_STATE = BaseService.STATE_NO_IMAGES - return self._CURRENT_STATE - - return BaseService.updateState(self) - - def explainState(self): - if self._CURRENT_STATE == BaseService.STATE_NO_IMAGES: - if self.subState == USB_Photos.SUBSTATE_NOT_CONNECTED: - return "No storage device (e.g. USB-stick) has been detected" - else: - return 'Place images and/or albums inside a "photoframe"-directory on your storage device' - return None - - def getMessages(self): - # display a message indicating which storage device is being used or an error messing if no suitable storage device could be found - if self.device and os.path.exists(self.baseDir): - msgs = [ - { - 'level': 'SUCCESS', - 'message': 'Storage device "%s" is connected' % self.device.getName(), - 'link': None - } - ] - msgs.extend(BaseService.getMessages(self)) - else: - msgs = [ - { - 'level': 'ERROR', - 'message': 'No storage device could be found that contains the "/photoframe/"-directory! Try to reboot or manually mount the desired storage device to "%s"' % self.usbDir, - 'link': None - } - ] - return msgs - - def detectAllStorageDevices(self, onlyMounted=False, onlyUnmounted=False, reverse=False): - candidates = [] - for root, dirs, files in os.walk('/sys/block/'): - for device in dirs: - result = subprocess.check_output(['udevadm', 'info', '--query=property', '/sys/block/' + device]) - if result and 'ID_BUS=usb' in result: - values = {} - for line in result.split('\n'): - line = line.strip() - if line == '': continue - k,v = line.split('=', 1) - values[k] = v - if 'DEVNAME' in values: - #logging.info('Found USB device: ' + values['DEVNAME']) - # Now, locate the relevant partition - result = subprocess.check_output(['lsblk', '-bOJ', values['DEVNAME']]) - if result is not None: - partitions = json.loads(result)['blockdevices'][0]['children'] - for partition in partitions: - if partition['fstype'] in USB_Photos.SUPPORTED_FILESYSTEMS: - # Final test - if (partition['mountpoint'] is None and onlyMounted) or (partition['mountpoint'] is not None and onlyUnmounted): - continue - # Convert this into candidate info - candidate = USB_Photos.StorageUnit() - candidate.setDevice(os.path.join(os.path.dirname(values['DEVNAME']), partition['name'])) - if partition['label'] is not None and partition['label'].strip() != '': - candidate.setLabel(partition['label'].strip()) - candidate.setUUID(partition['uuid']) - candidate.setSize(partition['size']) - candidate.setFilesystem(partition['fstype']) - candidate.setHotplug(partition['hotplug'] == '1') - candidate.setMountpoint(partition['mountpoint']) - candidate.setFreshness(values['USEC_INITIALIZED']) - candidates.append(candidate) - # Return a list with the freshest device first (ie, last mounted) - candidates.sort(key=lambda x: x.freshness, reverse=True) - return candidates - - def mountStorageDevice(self): - if not os.path.exists(self.usbDir): - cmd = ["mkdir", self.usbDir] - try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - logging.exception('Unable to create directory: %s' % cmd[-1]) - logging.error('Output: %s' % repr(e.output)) - - candidates = self.detectAllStorageDevices(onlyUnmounted=True) - - # unplugging/replugging usb-stick causes system to detect it as a new storage device! - for candidate in candidates: - cmd = ['sudo', '-n', 'mount', candidate.device, self.usbDir] - try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT) - logging.info("USB-device '%s' successfully mounted to '%s'!" % (cmd[-2], cmd[-1])) - if os.path.exists(self.baseDir): - self.device = candidate - self.checkForInvalidKeywords() - return True - except subprocess.CalledProcessError as e: - logging.warning('Unable to mount storage device "%s" to "%s"!' % (candidate.device, self.usbDir)) - logging.warning('Output: %s' % repr(e.output)) - self.unmountBaseDir() - - logging.debug("unable to mount any storage device to '%s'" % (self.usbDir)) - return False - - def unmountBaseDir(self): - cmd = ['sudo', '-n', 'umount', self.usbDir] - try: - subprocess.check_output(cmd, stderr=subprocess.STDOUT) - except subprocess.CalledProcessError: - logging.debug("unable to UNMOUNT '%s'" % self.usbDir) - - # All images directly inside '/photoframe' directory will be displayed without any keywords - def getBaseDirImages(self): - return [x for x in os.listdir(self.baseDir) if os.path.isfile(os.path.join(self.baseDir, x))] - - def getAllAlbumNames(self): - return [x for x in os.listdir(self.baseDir) if os.path.isdir(os.path.join(self.baseDir, x))] - - def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize): - if self.device is None: - return BaseService.createImageHolder(self).setError('No external storage device detected! Please connect a USB-stick!\n\n Place albums inside /photoframe/{album_name} directory and add each {album_name} as keyword.\n\nAlternatively, put images directly inside the "/photoframe/"-directory on your storage device.') - - result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize) - if result is not None: - return result - - if os.path.exists(self.usbDir): - return BaseService.createImageHolder(self).setError('No images could be found on storage device "%s"!\n\nPlease place albums inside /photoframe/{album_name} directory and add each {album_name} as keyword.\n\nAlternatively, put images directly inside the "/photoframe/"-directory on your storage device.' % self.device.getName()) - else: - return BaseService.createImageHolder(self).setError('No external storage device detected! Please connect a USB-stick!\n\n Place albums inside /photoframe/{album_name} directory and add each {album_name} as keyword.\n\nAlternatively, put images directly inside the "/photoframe/"-directory on your storage device.') - - def getImagesFor(self, keyword): - if not os.path.isdir(self.baseDir): - return [] - images = [] - if keyword == "_PHOTOFRAME_": - files = [x for x in self.getBaseDirImages() if not x.startswith(".")] - images = self.getAlbumInfo(self.baseDir, files) - else: - if os.path.isdir(os.path.join(self.baseDir, keyword)): - files = [x for x in os.listdir(os.path.join(self.baseDir, keyword)) if not x.startswith(".")] - images = self.getAlbumInfo(os.path.join(self.baseDir, keyword), files) - else: - logging.warning("The album '%s' does not exist. Did you unplug the storage device associated with '%s'?!" % (os.path.join(self.baseDir, keyword), self.device)) - return images - - def getAlbumInfo(self, path, files): - images = [] - for filename in files: - fullFilename = os.path.join(path, filename) - dim = helper.getImageSize(fullFilename) - readable = True - if dim is None: +class USB_Photos(BaseService): + SERVICE_NAME = 'USB-Photos' + SERVICE_ID = 4 + + SUPPORTED_FILESYSTEMS = ['exfat', 'vfat', 'ntfs', 'ext2', 'ext3', 'ext4'] + SUBSTATE_NOT_CONNECTED = 404 + + INDEX = 0 + + class StorageUnit: + def __init__(self): + self.device = None + self.uuid = None + self.size = 0 + self.fs = None + self.hotplug = False + self.mountpoint = None + self.freshness = 0 + self.label = None + + def setLabel(self, label): + self.label = label + return self + + def setDevice(self, device): + self.device = device + return self + + def setUUID(self, uuid): + self.uuid = uuid + return self + + def setSize(self, size): + self.size = int(size) + return self + + def setFilesystem(self, fs): + self.fs = fs + return self + + def setHotplug(self, hotplug): + self.hotplug = hotplug + return self + + def setMountpoint(self, mountpoint): + self.mountpoint = mountpoint + return self + + def setFreshness(self, freshness): + self.freshness = int(freshness) + return self + + def getName(self): + if self.label is None: + return self.device + return self.label + + def __init__(self, configDir, id, name): + BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=False) + + def preSetup(self): + USB_Photos.INDEX += 1 + self.usbDir = "/mnt/usb%d" % USB_Photos.INDEX + self.baseDir = os.path.join(self.usbDir, "photoframe") + + self.device = None + if not os.path.exists(self.baseDir): + self.mountStorageDevice() + elif len(os.listdir(self.baseDir)) == 0: + self.unmountBaseDir() + self.mountStorageDevice() + else: + self.checkForInvalidKeywords() + for device in self.detectAllStorageDevices(onlyMounted=True): + if device.mountpoint == self.usbDir: + self.device = device + logging.info("USB-Service has detected device '%s'" % self.device.device) + break + if self.device is None: + # Service should still be working fine + logging.warning("Unable to determine which storage device is mounted to '%s'" % self.usbDir) + + def helpKeywords(self): + return "Place photo albums in /photoframe/{album_name} on your usb-device.\n" \ + "Use the {album_name} as keyword (CasE-seNsitiVe!).\nIf you want to display all albums simply write " \ + "'ALLALBUMS' as keyword.\nAlternatively, place images directly inside the '/photoframe/' directory." + + def validateKeywords(self, keyword): + if keyword != 'ALLALBUMS' and keyword != '_PHOTOFRAME_': + if keyword not in self.getAllAlbumNames(): + return {'error': 'No such album "%s"' % keyword, 'keywords': keyword} + + return BaseService.validateKeywords(self, keyword) + + def getKeywords(self): + if not os.path.exists(self.baseDir): + return [] + + keywords = list(self._STATE['_KEYWORDS']) + if "ALLALBUMS" in keywords: + # No, you can't have an album called /photoframe/ALLALBUMS ... + keywords.remove("ALLALBUMS") + albums = self.getAllAlbumNames() + keywords.extend([a for a in albums if a not in keywords]) + + if "ALLALBUMS" in albums: + logging.error("You should not have a album called 'ALLALBUMS'!") + + if len(keywords) == 0 and len(self.getBaseDirImages()) != 0 and "_PHOTOFRAME_" not in keywords: + keywords.append("_PHOTOFRAME_") + # _PHOTOFRAME_ can be manually deleted via web interface if other keywords are specified! + + self._STATE['_KEYWORDS'] = keywords + self.saveState() + return keywords + + def checkForInvalidKeywords(self): + index = len(self._STATE['_KEYWORDS'])-1 + for keyword in reversed(self._STATE['_KEYWORDS']): + if keyword == "_PHOTOFRAME_": + if len(self.getBaseDirImages()) == 0: + logging.debug( + "USB-Service: removing keyword '%s' because there are " + "no images directly inside the basedir!" % keyword) + self.removeKeywords(index) + elif keyword not in self.getAllAlbumNames(): + logging.info("USB-Service: removing invalid keyword: %s" % keyword) + self.removeKeywords(index) + index -= 1 + self.saveState() + + def updateState(self): + self.subState = None + if not os.path.exists(self.baseDir): + if not self.mountStorageDevice(): + self._CURRENT_STATE = BaseService.STATE_NO_IMAGES + self.subState = USB_Photos.SUBSTATE_NOT_CONNECTED + return self._CURRENT_STATE + if len(self.getAllAlbumNames()) == 0 and len(self.getBaseDirImages()) == 0: + self._CURRENT_STATE = BaseService.STATE_NO_IMAGES + return self._CURRENT_STATE + + return BaseService.updateState(self) + + def explainState(self): + if self._CURRENT_STATE == BaseService.STATE_NO_IMAGES: + if self.subState == USB_Photos.SUBSTATE_NOT_CONNECTED: + return "No storage device (e.g. USB-stick) has been detected" + else: + return 'Place images and/or albums inside a "photoframe"-directory on your storage device' + return None + + def getMessages(self): + # display a message indicating which storage device is being used or + # an error messing if no suitable storage device could be found + if self.device and os.path.exists(self.baseDir): + msgs = [ + { + 'level': 'SUCCESS', + 'message': 'Storage device "%s" is connected' % self.device.getName(), + 'link': None + } + ] + msgs.extend(BaseService.getMessages(self)) + else: + msgs = [ + { + 'level': 'ERROR', + 'message': 'No storage device could be found that contains the "/photoframe/"-directory! ' + 'Try to reboot or manually mount the desired storage device to "%s"' % self.usbDir, + 'link': None + } + ] + return msgs + + def detectAllStorageDevices(self, onlyMounted=False, onlyUnmounted=False, reverse=False): + candidates = [] + for root, dirs, files in os.walk('/sys/block/'): + for device in dirs: + result = subprocess.check_output(['udevadm', 'info', '--query=property', '/sys/block/' + device]) + if result and 'ID_BUS=usb' in result: + values = {} + for line in result.split('\n'): + line = line.strip() + if line == '': + continue + k, v = line.split('=', 1) + values[k] = v + if 'DEVNAME' in values: + # Now, locate the relevant partition + result = subprocess.check_output(['lsblk', '-bOJ', values['DEVNAME']]) + if result is not None: + partitions = json.loads(result)['blockdevices'][0]['children'] + for partition in partitions: + if partition['fstype'] in USB_Photos.SUPPORTED_FILESYSTEMS: + # Final test + if (partition['mountpoint'] is None and onlyMounted) \ + or (partition['mountpoint'] is not None and onlyUnmounted): + continue + + # Convert this into candidate info + candidate = USB_Photos.StorageUnit() + candidate.setDevice(os.path.join( + os.path.dirname(values['DEVNAME']), partition['name'])) + if partition['label'] is not None and partition['label'].strip() != '': + candidate.setLabel(partition['label'].strip()) + candidate.setUUID(partition['uuid']) + candidate.setSize(partition['size']) + candidate.setFilesystem(partition['fstype']) + candidate.setHotplug(partition['hotplug'] == '1') + candidate.setMountpoint(partition['mountpoint']) + candidate.setFreshness(values['USEC_INITIALIZED']) + candidates.append(candidate) + # Return a list with the freshest device first (ie, last mounted) + candidates.sort(key=lambda x: x.freshness, reverse=True) + return candidates + + def mountStorageDevice(self): + if not os.path.exists(self.usbDir): + cmd = ["mkdir", self.usbDir] + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + logging.exception('Unable to create directory: %s' % cmd[-1]) + logging.error('Output: %s' % repr(e.output)) + + candidates = self.detectAllStorageDevices(onlyUnmounted=True) + + # unplugging/replugging usb-stick causes system to detect it as a new storage device! + for candidate in candidates: + cmd = ['sudo', '-n', 'mount', candidate.device, self.usbDir] + try: + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + logging.info("USB-device '%s' successfully mounted to '%s'!" % (cmd[-2], cmd[-1])) + if os.path.exists(self.baseDir): + self.device = candidate + self.checkForInvalidKeywords() + return True + except subprocess.CalledProcessError as e: + logging.warning('Unable to mount storage device "%s" to "%s"!' % (candidate.device, self.usbDir)) + logging.warning('Output: %s' % repr(e.output)) + self.unmountBaseDir() + + logging.debug("unable to mount any storage device to '%s'" % (self.usbDir)) + return False + + def unmountBaseDir(self): + cmd = ['sudo', '-n', 'umount', self.usbDir] try: - with open(fullFilename, 'rb') as f: - f.read(1) - logging.warning('File %s has unknown format, skipping', fullFilename) - continue - except: - readable = False - - if os.path.exists(fullFilename) and readable: - item = BaseService.createImageHolder(self) - item.setId(self.hashString(fullFilename)) - item.setUrl(fullFilename).setSource(fullFilename) - item.setMimetype(helper.getMimetype(fullFilename)) - item.setDimensions(dim['width'], dim['height']) - item.setFilename(filename) - images.append(item) - else: - logging.warning('File %s could not be read. Could be USB issue, try rebooting', fullFilename) - return images - - def requestUrl(self, url, destination=None, params=None, data=None, usePost=False): - # pretend to download the file (for compatability with 'selectImageFromAlbum' of baseService) - # instead just cache a scaled version of the file and return {status: 200} - result = RequestResult() - - filename = url - recSize = None - - if destination is None or not os.path.isfile(filename): - result.setResult(RequestResult.SUCCESS).setHTTPCode(400) - elif recSize is not None and helper.scaleImage(filename, destination, recSize): - result.setFilename(destination) - result.setResult(RequestResult.SUCCESS).setHTTPCode(200) - elif helper.copyFile(filename, destination): - result.setFilename(destination) - result.setResult(RequestResult.SUCCESS).setHTTPCode(200) - else: - result.setResult(RequestResult.SUCCESS).setHTTPCode(418) - return result + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError: + logging.debug("unable to UNMOUNT '%s'" % self.usbDir) + + # All images directly inside '/photoframe' directory will be displayed without any keywords + def getBaseDirImages(self): + return [x for x in os.listdir(self.baseDir) if os.path.isfile(os.path.join(self.baseDir, x))] + + def getAllAlbumNames(self): + return [x for x in os.listdir(self.baseDir) if os.path.isdir(os.path.join(self.baseDir, x))] + + def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize): + if self.device is None: + return BaseService.createImageHolder(self) \ + .setError('No external storage device detected! ' + 'Please connect a USB-stick!\n\n Place albums inside /photoframe/{album_name} directory and ' + 'add each {album_name} as keyword.\n\nAlternatively, put images directly inside the ' + '"/photoframe/"-directory on your storage device.') + + result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize) + if result is not None: + return result + + if os.path.exists(self.usbDir): + return BaseService.createImageHolder(self) \ + .setError('No images could be found on storage device "%s"!\n\n' + 'Please place albums inside /photoframe/{album_name} directory and add each {album_name} ' + 'as keyword.\n\nAlternatively, put images directly inside the ' + '"/photoframe/"-directory on your storage device.' % self.device.getName()) + else: + return BaseService.createImageHolder(self) \ + .setError('No external storage device detected! Please connect a USB-stick!\n\n' + 'Place albums inside /photoframe/{album_name} directory and add each {album_name} as keyword.' + '\n\nAlternatively, put images directly inside the "/photoframe/"-directory ' + 'on your storage device.') + + def getImagesFor(self, keyword): + if not os.path.isdir(self.baseDir): + return [] + images = [] + if keyword == "_PHOTOFRAME_": + files = [x for x in self.getBaseDirImages() if not x.startswith(".")] + images = self.getAlbumInfo(self.baseDir, files) + else: + if os.path.isdir(os.path.join(self.baseDir, keyword)): + files = [x for x in os.listdir(os.path.join(self.baseDir, keyword)) if not x.startswith(".")] + images = self.getAlbumInfo(os.path.join(self.baseDir, keyword), files) + else: + logging.warning( + "The album '%s' does not exist. Did you unplug " + "the storage device associated with '%s'?!" % (os.path.join(self.baseDir, keyword), self.device) + ) + return images + + def getAlbumInfo(self, path, files): + images = [] + for filename in files: + fullFilename = os.path.join(path, filename) + dim = helper.getImageSize(fullFilename) + readable = True + if dim is None: + try: + with open(fullFilename, 'rb') as f: + f.read(1) + logging.warning('File %s has unknown format, skipping', fullFilename) + continue + except Exception: + readable = False + + if os.path.exists(fullFilename) and readable: + item = BaseService.createImageHolder(self) + item.setId(self.hashString(fullFilename)) + item.setUrl(fullFilename).setSource(fullFilename) + item.setMimetype(helper.getMimetype(fullFilename)) + item.setDimensions(dim['width'], dim['height']) + item.setFilename(filename) + images.append(item) + else: + logging.warning('File %s could not be read. Could be USB issue, try rebooting', fullFilename) + return images + + def requestUrl(self, url, destination=None, params=None, data=None, usePost=False): + # pretend to download the file (for compatability with 'selectImageFromAlbum' of baseService) + # instead just cache a scaled version of the file and return {status: 200} + result = RequestResult() + + filename = url + recSize = None + + if destination is None or not os.path.isfile(filename): + result.setResult(RequestResult.SUCCESS).setHTTPCode(400) + elif recSize is not None and helper.scaleImage(filename, destination, recSize): + result.setFilename(destination) + result.setResult(RequestResult.SUCCESS).setHTTPCode(200) + elif helper.copyFile(filename, destination): + result.setFilename(destination) + result.setResult(RequestResult.SUCCESS).setHTTPCode(200) + else: + result.setResult(RequestResult.SUCCESS).setHTTPCode(418) + return result diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..be62af3 --- /dev/null +++ b/tox.ini @@ -0,0 +1,6 @@ +[flake8] +max-line-length = 120 +exclude = + # No need to traverse our git directory + .git + From 6ee0c85482385a6de891a0a992661a9d278cfd0a Mon Sep 17 00:00:00 2001 From: Henric Andersson Date: Sun, 21 Feb 2021 22:11:20 -0800 Subject: [PATCH 03/20] Fixed PEP8 issues, hopefully it still works --- modules/cachemanager.py | 22 +++++++--------------- modules/colormatch.py | 14 ++++++++------ modules/debug.py | 4 +--- modules/dedupe.py | 6 +++--- modules/display.py | 25 ++++++++++++++++++++++--- modules/drivers.py | 26 ++++++++++++++------------ modules/helper.py | 39 ++++++++++++++++++++++++++++----------- modules/history.py | 2 +- modules/images.py | 16 +++++++++------- modules/memory.py | 2 +- modules/oauth.py | 13 +++++++------ modules/path.py | 2 +- modules/remember.py | 2 +- modules/server.py | 14 +++++++------- modules/servicemanager.py | 15 ++++++++++----- modules/settings.py | 12 ++++++++---- modules/shutdown.py | 4 ++-- modules/slideshow.py | 6 ++++-- modules/sysconfig.py | 8 ++++---- modules/timekeeper.py | 22 ++++++++++++++++++++-- routes/debug.py | 12 ++++++++---- routes/details.py | 12 ++++++++---- routes/upload.py | 2 +- 23 files changed, 175 insertions(+), 105 deletions(-) diff --git a/modules/cachemanager.py b/modules/cachemanager.py index 4294f1f..c4040ad 100755 --- a/modules/cachemanager.py +++ b/modules/cachemanager.py @@ -21,7 +21,7 @@ from modules.path import path as syspath -### CONSTANTS ### +# CONSTANTS MIN = 60 HOUR = MIN * 60 @@ -34,8 +34,6 @@ GB = MB * 10**3 # NOTE: all values are in Bytes! -################## - class CacheManager: STATE_HEAPS = 0 @@ -74,7 +72,7 @@ def getCachedImage(self, cacheId, destination): shutil.copy(filename, destination) logging.debug('Cache hit, using %s as %s', cacheId, destination) return destination - except: + except Exception: logging.exception('Failed to copy cached image') return None @@ -91,7 +89,7 @@ def setCachedImage(self, filename, cacheId): shutil.copy(filename, cacheFile) logging.debug('Cached %s as %s', filename, cacheId) return filename - except: + except Exception: logging.exception('Failed to ownership of file') return None @@ -114,7 +112,7 @@ def empty(self, directory=syspath.CACHEFOLDER): freedUpSpace += os.stat(filename).st_size try: os.unlink(filename) - except: + except Exception: logging.exception('Failed to delete "%s"' % filename) logging.info("'%s' has been emptied" % directory) return freedUpSpace @@ -140,7 +138,7 @@ def deleteOldFiles(self, topPath, minAge): except OSError as e: logging.warning("unable to delete file '%s'!" % filename) logging.exception("Output: "+e.strerror) - except: + except Exception: logging.exception('Failed to clean "%s"', topPath) return freedUpSpace @@ -154,16 +152,10 @@ def getDirSize(self, path): # classify disk space usage into five differnt states based on free/total ratio def getDiskSpaceState(self, path): # all values are in bytes! - #dirSize = float(self.getDirSize(path)) - stat = os.statvfs(path) total = float(stat.f_blocks*stat.f_bsize) free = float(stat.f_bfree*stat.f_bsize) - #logging.debug("'%s' takes up %s" % (path, CacheManager.formatBytes(dirSize))) - #logging.debug("free space on partition: %s" % CacheManager.formatBytes(free)) - #logging.debug("total space on partition: %s" % CacheManager.formatBytes(total)) - if free < 50*MB: return CacheManager.STATE_FULL elif free/total < 0.1: @@ -176,10 +168,10 @@ def getDiskSpaceState(self, path): return CacheManager.STATE_HEAPS # Free up space of any tmp/cache folder - # Frequently calling this function will make sure, less important files are deleted before having to delete more important ones. + # Frequently calling this function will make sure, less important files + # are deleted before having to delete more important ones. # Of course a manual cache reset is possible via the photoframe web interface def garbageCollect(self, lessImportantDirs=[]): - #logging.debug("Garbage Collector started!") state = self.getDiskSpaceState(syspath.CACHEFOLDER) freedUpSpace = 0 if state == CacheManager.STATE_FULL: diff --git a/modules/colormatch.py b/modules/colormatch.py index 2deeaed..438b5be 100755 --- a/modules/colormatch.py +++ b/modules/colormatch.py @@ -49,10 +49,10 @@ def hasSensor(self): return self.sensor def hasTemperature(self): - return self.temperature != None + return self.temperature is not None def hasLux(self): - return self.lux != None + return self.lux is not None def getTemperature(self): return self.temperature @@ -88,7 +88,7 @@ def adjust(self, filename, filenameTemp, temperature=None): logging.warning('colormatch called without filename extension, lingering .cache file will stay behind') return result - except: + except Exception: logging.exception('Unable to run %s:', self.script) return False @@ -137,7 +137,7 @@ def _temperature_and_lux(self, data): def run(self): try: bus = smbus.SMBus(1) - except: + except Exception: logging.info('No SMB subsystem, color sensor unavailable') return # I2C address 0x29 @@ -145,7 +145,7 @@ def run(self): # Register addresses must be OR'ed with 0x80 try: bus.write_byte(0x29, 0x80 | 0x12) - except: + except Exception: logging.info('ColorSensor not available') return ver = bus.read_byte(0x29) @@ -154,7 +154,9 @@ def run(self): # Make sure we have the needed script if not os.path.exists(self.script): logging.info( - 'No color temperature script, download it from http://www.fmwconcepts.com/imagemagick/colortemp/index.php and save as "%s"' % self.script) + 'No color temperature script, download it from ' + 'http://www.fmwconcepts.com/imagemagick/colortemp/index.php and save as "%s"' % self.script + ) self.allowAdjust = False self.allowAdjust = True diff --git a/modules/debug.py b/modules/debug.py index 0f67ed9..d350bbd 100755 --- a/modules/debug.py +++ b/modules/debug.py @@ -34,12 +34,10 @@ def _stringify(args): def subprocess_call(cmds, stderr=None, stdout=None): - #logging.debug('subprocess.call(%s)', _stringify(cmds)) return subprocess.call(cmds, stderr=stderr, stdout=stdout) def subprocess_check_output(cmds, stderr=None): - #logging.debug('subprocess.check_output(%s)', _stringify(cmds)) return subprocess.check_output(cmds, stderr=stderr) @@ -57,7 +55,7 @@ def stacktrace(): def logfile(all=False): stats = os.stat('/var/log/syslog') - cmd = 'grep -a "photoframe\[" /var/log/syslog | tail -n 100' + cmd = 'grep -a "photoframe\\[" /var/log/syslog | tail -n 100' title = 'Last 100 lines from the photoframe log' if all: title = 'Last 100 lines from the system log (/var/log/syslog)' diff --git a/modules/dedupe.py b/modules/dedupe.py index b4a3df5..8e1f931 100755 --- a/modules/dedupe.py +++ b/modules/dedupe.py @@ -19,11 +19,11 @@ class DedupeManager: def __init__(self, memoryLocation): try: - #from PIL import Image - #import imagehash + # from PIL import Image + # import imagehash self.hasImageHash = True logging.info('ImageHash functionality is available') - except: + except Exception: self.hasImageHash = False logging.info('ImageHash functionality is unavailable') diff --git a/modules/display.py b/modules/display.py index e8a86f9..2ae3ea5 100755 --- a/modules/display.py +++ b/modules/display.py @@ -259,8 +259,24 @@ def enable(self, enable, force=False): stderr=self.void, stdout=self.void) time.sleep(1) debug.subprocess_call(['/bin/fbset', '-fb', self.getDevice(), '-depth', '8'], stderr=self.void) - debug.subprocess_call(['/bin/fbset', '-fb', self.getDevice(), '-depth', str(self.depth), '-xres', str( - self.width), '-yres', str(self.height), '-vxres', str(self.width), '-vyres', str(self.height)], stderr=self.void) + debug.subprocess_call( + [ + '/bin/fbset', + '-fb', + self.getDevice(), + '-depth', + str(self.depth), + '-xres', + str(self.width), + '-yres', + str(self.height), + '-vxres', + str(self.width), + '-vyres', + str(self.height) + ], + stderr=self.void + ) else: debug.subprocess_call(['/usr/bin/vcgencmd', 'display_power', '1'], stderr=self.void) else: @@ -334,7 +350,10 @@ def current(self): output = debug.subprocess_check_output(['/opt/vc/bin/tvservice', '-s'], stderr=subprocess.STDOUT) # state 0x120006 [DVI DMT (82) RGB full 16:9], 1920x1080 @ 60.00Hz, progressive m = re.search( - 'state 0x[0-9a-f]* \[([A-Z]*) ([A-Z]*) \(([0-9]*)\) [^,]*, ([0-9]*)x([0-9]*) \@ ([0-9]*)\.[0-9]*Hz, (.)', output) + 'state 0x[0-9a-f]* \\[([A-Z]*) ([A-Z]*) \\(([0-9]*)\\) [^,]*, ' + '([0-9]*)x([0-9]*) \\@ ([0-9]*)\\.[0-9]*Hz, (.)', + output + ) if m is None: return None result = { diff --git a/modules/drivers.py b/modules/drivers.py index 9656dc0..2892c8b 100644 --- a/modules/drivers.py +++ b/modules/drivers.py @@ -31,7 +31,7 @@ def __init__(self): if not os.path.exists(path.DRV_EXTERNAL): try: os.mkdir(path.DRV_EXTERNAL) - except: + except Exception: logging.exception('Unable to create "%s"', path.DRV_EXTERNAL) def _list_dir(self, path): @@ -63,7 +63,7 @@ def _find(self, filename, basedir): def _deletefolder(self, folder): try: shutil.rmtree(folder) - except: + except Exception: logging.exception('Failed to delete "%s"', folder) def _parse(self, installer): @@ -123,13 +123,15 @@ def _parse(self, installer): elif value.lower() in ['false', 'no']: value = False config['options'][key] = value - except: + except Exception: logging.exception('Failed to read INSTALL manifest') return None # Support old INSTALL format if 'config' not in config: - logging.info('All drivers have typically ONE config value, this must be an old INSTALL file, try to compensate') + logging.info( + 'All drivers have typically ONE config value, this must be an old INSTALL file, trying to compensate' + ) config['config'] = [] for k in config['options']: config['config'].append('%s=%s' % (k, config['options'][k])) @@ -147,7 +149,7 @@ def install(self, file): try: result = subprocess.check_call( ['/usr/bin/unzip', file, '-d', os.path.join(folder, extra)], stdout=self.void, stderr=self.void) - except: + except Exception: result = 255 if result != 0: @@ -183,7 +185,7 @@ def install(self, file): files.append({'src': dst, 'dst': entry['dst']}) try: shutil.copyfile(os.path.join(folder, extra, src), os.path.join(dstfolder, dst)) - except: + except Exception: logging.exception('Failed to copy "%s" to "%s"', os.path.join( folder, extra, src), os.path.join(dstfolder, dst)) # Shitty, but we cannot leave this directory with partial files @@ -203,7 +205,7 @@ def isint(self, value): try: int(value) return True - except: + except Exception: return False def activate(self, driver=None): @@ -225,7 +227,7 @@ def activate(self, driver=None): with open(os.path.join(driverlist[driver], 'manifest.json'), 'rb') as f: config = json.load(f) root = driverlist[driver] - except: + except Exception: logging.exception('Failed to load manifest for %s', driver) return None # Reformat old @@ -242,7 +244,7 @@ def activate(self, driver=None): for copy in config['install']: try: shutil.copyfile(os.path.join(root, copy['src']), copy['dst']) - except: + except Exception: logging.exception('Failed to copy "%s" to "%s"', copy['src'], copy['dst']) return None @@ -255,7 +257,7 @@ def activate(self, driver=None): if line == drivers.MARKER: break lines.append(line) - except: + except Exception: logging.exception('Failed to read /boot/config.txt') return None @@ -270,7 +272,7 @@ def activate(self, driver=None): with open('/boot/config.txt.new', 'wb') as f: for line in lines: f.write('%s\n' % line) - except: + except Exception: logging.exception('Failed to generate new config.txt') return None @@ -283,7 +285,7 @@ def activate(self, driver=None): os.unlink('/boot/config.txt.old') else: os.rename('/boot/config.txt.old', '/boot/config.txt.original') - except: + except Exception: logging.exception('Failed to activate new config.txt, you may need to restore the config.txt') return None if 'special' in config: diff --git a/modules/helper.py b/modules/helper.py index 0e231da..2acc006 100755 --- a/modules/helper.py +++ b/modules/helper.py @@ -22,7 +22,8 @@ import random import time -# A regular expression to determine whether a url is valid or not (e.g. "www.example.de/someImg.jpg" is missing "http://") +# A regular expression to determine whether a url is valid or not +# (e.g. "www.example.de/someImg.jpg" is missing "http://") VALID_URL_REGEX = re.compile( r'^(?:http|ftp)s?://' # http:// or https:// r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... @@ -84,7 +85,7 @@ def getDeviceIp(): except ImportError: logging.error('User has not installed python-netifaces, using checkNetwork() instead (depends on internet)') return helper._checkNetwork() - except: + except Exception: logging.exception('netifaces call failed, using checkNetwork() instead (depends on internet)') return helper._checkNetwork() @@ -97,7 +98,7 @@ def _checkNetwork(): ip = s.getsockname()[0] s.close() - except: + except Exception: logging.exception('Failed to get IP via old method') return ip @@ -125,7 +126,7 @@ def getMimetype(filename): with open(os.devnull, 'wb') as void: try: output = subprocess.check_output(cmd, stderr=void).strip("\n") - m = re.match('[^\:]+\: *([^;]+)', output) + m = re.match('[^\\:]+\\: *([^;]+)', output) if m: mimetype = m.group(1) except subprocess.CalledProcessError: @@ -137,7 +138,7 @@ def getMimetype(filename): def copyFile(orgFilename, newFilename): try: shutil.copyfile(orgFilename, newFilename) - except: + except Exception: logging.exception('Unable copy file from "%s" to "%s"' % (orgFilename, newFilename)) return False return True @@ -170,7 +171,7 @@ def getImageSize(filename): with open(os.devnull, 'wb') as void: try: output = subprocess.check_output(['/usr/bin/identify', filename], stderr=void) - except: + except Exception: logging.exception('Failed to run identify to get image dimensions on %s', filename) return None @@ -252,12 +253,26 @@ def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoCho adjWidth = displayWidth adjHeight = int(float(displayWidth) / oar) logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d --> cropped to %dx%d', - width, height, displayWidth, displayHeight, adjWidth, adjHeight, displayWidth, displayHeight) + width, + height, + displayWidth, + displayHeight, + adjWidth, + adjHeight, + displayWidth, + displayHeight) else: adjWidth = int(float(displayHeight) * oar) adjHeight = displayHeight logging.debug('Size of image is %dx%d, screen is %dx%d. New size is %dx%d --> cropped to %dx%d', - width, height, displayWidth, displayHeight, adjWidth, adjHeight, displayWidth, displayHeight) + width, + height, + displayWidth, + displayHeight, + adjWidth, + adjHeight, + displayWidth, + displayHeight) cmd = None try: @@ -312,7 +327,7 @@ def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoCho '-composite', filenameProcessed ] - except: + except Exception: logging.exception('Error building command line') logging.debug('Filename: ' + repr(filename)) logging.debug('filenameProcessed: ' + repr(filenameProcessed)) @@ -346,7 +361,7 @@ def timezoneSet(zone): try: with open(os.devnull, 'wb') as void: result = subprocess.check_call(['/usr/bin/timedatectl', 'set-timezone', zone], stderr=void) - except: + except Exception: logging.exception('Unable to change timezone') pass return result == 0 @@ -373,7 +388,9 @@ def waitForNetwork(funcNoNetwork, funcExit): def autoRotate(ifile): if not os.path.exists('/usr/bin/jpegexiforient'): logging.warning( - 'jpegexiforient is missing, no auto rotate available. Did you forget to run "apt install libjpeg-turbo-progs" ?') + 'jpegexiforient is missing, no auto rotate available. ' + 'Did you forget to run "apt install libjpeg-turbo-progs" ?' + ) return ifile p, f = os.path.split(ifile) diff --git a/modules/history.py b/modules/history.py index e55a10a..18e6c3f 100755 --- a/modules/history.py +++ b/modules/history.py @@ -37,7 +37,7 @@ def __init__(self, settings): for filename in [os.path.join(p, f) for f in files]: try: os.unlink(filename) - except: + except Exception: logging.exception('Failed to delete "%s"' % filename) def _find(self, file): diff --git a/modules/images.py b/modules/images.py index 7942913..7ebefc7 100755 --- a/modules/images.py +++ b/modules/images.py @@ -18,13 +18,15 @@ class ImageHolder: def __init__(self): - # "id" : a unique - preferably not-changing - ID to identify the same image in future requests, e.g. hashString(imageUrl) - # "mimetype" : the filetype you downloaded, for example "image/jpeg" - # "error" : None or a human readable text string as to why you failed - # "source" : Link to where the item came from or None if not provided - # "url": Link to the actual image file - # "dimensions": a key/value map containing "width" and "height" of the image - # can be None, but the service won't be able to determine a recommendedImageSize for 'addUrlParams' + # "id" : a unique - preferably not-changing - ID to identify the same image in future requests, + # e.g. hashString(imageUrl) + # "mimetype" : the filetype you downloaded, for example "image/jpeg" + # "error" : None or a human readable text string as to why you failed + # "source" : Link to where the item came from or None if not provided + # "url": Link to the actual image file + # "dimensions": a key/value map containing "width" and "height" of the image + # can be None, but the service won't be able to determine a recommendedImageSize + # for 'addUrlParams' # "filename": the original filename of the image or None if unknown (only used for debugging purposes) self.id = None self.mimetype = None diff --git a/modules/memory.py b/modules/memory.py index ad1ce9f..2be30e1 100755 --- a/modules/memory.py +++ b/modules/memory.py @@ -49,7 +49,7 @@ def _fetch(self, key): try: with open(os.path.join(self._DIR_MEMORY, '%s.json' % h), 'r') as f: self._MEMORY = json.load(f) - except: + except Exception: logging.exception('File %s is corrupt' % os.path.join(self._DIR_MEMORY, '%s.json' % h)) self._MEMORY = [] else: diff --git a/modules/oauth.py b/modules/oauth.py index 2513172..66862c1 100755 --- a/modules/oauth.py +++ b/modules/oauth.py @@ -41,7 +41,7 @@ def setOAuth(self, oauth): self.oauth = oauth def hasOAuth(self): - return self.oauth != None + return self.oauth is not None def getSession(self, refresh=False): if not refresh: @@ -50,7 +50,8 @@ def getSession(self, refresh=False): auth = OAuth2Session(self.oauth['client_id'], token=self.cbGetToken(), auto_refresh_kwargs={ - 'client_id': self.oauth['client_id'], 'client_secret': self.oauth['client_secret']}, + 'client_id': self.oauth['client_id'], + 'client_secret': self.oauth['client_secret']}, auto_refresh_url=self.oauth['token_uri'], token_updater=self.cbSetToken) return auth @@ -58,7 +59,7 @@ def getSession(self, refresh=False): def request(self, uri, destination=None, params=None, data=None, usePost=False): ret = RequestResult() result = None - stream = destination != None + stream = destination is not None tries = 0 while tries < 5: @@ -89,7 +90,7 @@ def request(self, uri, destination=None, params=None, data=None, usePost=False): except InvalidGrantError: logging.error('Token is no longer valid, need to re-authenticate') raise RequestInvalidToken - except: + except Exception: logging.exception('Issues downloading') time.sleep(tries / 10) # Back off 10, 20, ... depending on tries tries += 1 @@ -107,7 +108,7 @@ def request(self, uri, destination=None, params=None, data=None, usePost=False): handle.write(chunk) ret.setResult(RequestResult.SUCCESS).setHTTPCode(result.status_code) ret.setHeaders(result.headers) - except: + except Exception: logging.exception('Failed to download %s' % uri) ret.setResult(RequestResult.FAILED_SAVING) else: @@ -147,6 +148,6 @@ def complete(self, url): self.cbSetToken(token) return True - except: + except Exception: logging.exception('Failed to complete OAuth') return False diff --git a/modules/path.py b/modules/path.py index c97e5f3..da9f35a 100755 --- a/modules/path.py +++ b/modules/path.py @@ -48,7 +48,7 @@ def validate(self): if not os.path.exists(path.CONFIGFOLDER): try: os.mkdir(path.CONFIGFOLDER) - except: + except Exception: logging.exception('Unable to create configuration directory, cannot start') return False elif not os.path.isdir(path.CONFIGFOLDER): diff --git a/modules/remember.py b/modules/remember.py index 64e19bd..b321443 100644 --- a/modules/remember.py +++ b/modules/remember.py @@ -33,7 +33,7 @@ def __init__(self, filename, count): self.debug() else: self.memory = {'seen': [], 'count': count} - except: + except Exception: logging.exception('Failed to load database') self.memory = {'seen': [], 'count': count} diff --git a/modules/server.py b/modules/server.py index b0418e1..ad3a186 100755 --- a/modules/server.py +++ b/modules/server.py @@ -95,7 +95,7 @@ def stop(self): else: logging.error('Unable to stop webserver, cannot find shutdown() function') return False - except: + except Exception: # We're not running with request, so... raise RuntimeError('Server shutdown') @@ -120,13 +120,13 @@ def _showException(self, e): message = str(e) else: code = 500 - #exc_type, exc_value, exc_traceback = sys.exc_info() lines = traceback.format_exc().splitlines() - #issue = lines[-1] message = ''' - Internal error

Uh oh, something went wrong...

+ Internal error +

Uh oh, something went wrong...

Please go to github - and see if this is a known issue, if not, feel free to file a new issue with the + and see if this is a known issue, if not, feel free to file a + new issue with the following information:
'''
             for line in lines:
@@ -160,13 +160,13 @@ def _registerHandlers(self):
                     for line in f:
                         line = line.strip()
                         if line.startswith('class ') and line.endswith('(BaseRoute):'):
-                            m = re.search('class +([^\(]+)\(', line)
+                            m = re.search('class +([^\\(]+)\\(', line)
                             if m is not None:
                                 klass = self._instantiate(item[0:-3], m.group(1))
                                 if klass.SIMPLE:
                                     try:
                                         route = eval('klass()')
                                         self.registerHandler(route)
-                                    except:
+                                    except Exception:
                                         logging.exception('Failed to create route for %s' % item)
                             break
diff --git a/modules/servicemanager.py b/modules/servicemanager.py
index d74d27a..c9303b1 100755
--- a/modules/servicemanager.py
+++ b/modules/servicemanager.py
@@ -74,18 +74,23 @@ def _detectServices(self):
                     for line in f:
                         line = line.strip()
                         if line.startswith('class ') and line.endswith('(BaseService):'):
-                            m = re.search('class +([^\(]+)\(', line)
+                            m = re.search('class +([^\\(]+)\\(', line)
                             if m is not None:
                                 klass = self._instantiate(item[0:-3], m.group(1))
                                 logging.info('Loading service %s from %s', klass.__name__, item)
-                                self._SVC_INDEX[m.group(1)] = {'id': klass.SERVICE_ID, 'name': klass.SERVICE_NAME,
-                                                               'module': item[0:-3], 'class': m.group(1), 'deprecated': klass.SERVICE_DEPRECATED}
+                                self._SVC_INDEX[m.group(1)] = {
+                                    'id': klass.SERVICE_ID,
+                                    'name': klass.SERVICE_NAME,
+                                    'module': item[0:-3],
+                                    'class': m.group(1),
+                                    'deprecated': klass.SERVICE_DEPRECATED
+                                }
                             break
 
     def _deletefolder(self, folder):
         try:
             shutil.rmtree(folder)
-        except:
+        except Exception:
             logging.exception('Failed to delete "%s"', folder)
 
     def _resolveService(self, id):
@@ -116,7 +121,7 @@ def _load(self):
         try:
             with open(self._CONFIGFILE, 'r') as f:
                 data = json.load(f)
-        except:
+        except Exception:
             logging.error('%s is corrupt, skipping' % self._CONFIGFILE)
             os.unlink(self._CONFIGFILE)
             return
diff --git a/modules/settings.py b/modules/settings.py
index a336477..0584b45 100755
--- a/modules/settings.py
+++ b/modules/settings.py
@@ -89,11 +89,15 @@ def load(self):
                     # Lastly, correct the tvservice field, should be "TEXT NUMBER TEXT"
                     # This is a little bit of a cheat
                     parts = self.settings['cfg']['tvservice'].split(' ')
-                    if len(parts) == 3 and type(self.convertToNative(parts[1])) != int and type(self.convertToNative(parts[2])) == int:
+                    if (
+                        len(parts) == 3
+                        and type(self.convertToNative(parts[1])) != int
+                        and type(self.convertToNative(parts[2])) == int
+                    ):
                         logging.debug('Reordering tvservice value due to old bug')
                         self.settings['cfg']['tvservice'] = "%s %s %s" % (parts[0], parts[2], parts[1])
                         self.save()
-                except:
+                except Exception:
                     logging.exception('Failed to load settings.json, corrupt file?')
                     return False
             # make sure old settings.json files are still compatible and get updated with new keys
@@ -112,7 +116,7 @@ def convertToNative(self, value):
             if '.' in value:
                 return float(value)
             return int(value)
-        except:
+        except Exception:
             return value
 
     def setUser(self, key, value):
@@ -128,7 +132,7 @@ def getUser(self, key=None):
         try:
             a = 1 / 0
             a += 1
-        except:
+        except Exception:
             logging.exception('Where did this come from??')
         return None
 
diff --git a/modules/shutdown.py b/modules/shutdown.py
index 493b208..62bde7a 100644
--- a/modules/shutdown.py
+++ b/modules/shutdown.py
@@ -40,13 +40,13 @@ def run(self):
         try:
             with open('/sys/class/gpio/export', 'wb') as f:
                 f.write('%d' % self.gpio)
-        except:
+        except Exception:
             # Usually it means we ran this before
             pass
         try:
             with open('/sys/class/gpio/gpio%d/direction' % self.gpio, 'wb') as f:
                 f.write('in')
-        except:
+        except Exception:
             logging.warn('Either no GPIO subsystem or no access')
             return
         with open('/sys/class/gpio/gpio%d/edge' % self.gpio, 'wb') as f:
diff --git a/modules/slideshow.py b/modules/slideshow.py
index ca509f5..a5c1642 100755
--- a/modules/slideshow.py
+++ b/modules/slideshow.py
@@ -177,9 +177,11 @@ def handleErrors(self, result):
         if result is None:
             serviceStates = self.services.getAllServiceStates()
             if len(serviceStates) == 0:
-                msg = 'Photoframe isn\'t ready yet\n\nPlease direct your webbrowser to\n\nhttp://%s:7777/\n\nand add one or more photo providers' % helper.getDeviceIp()
+                msg = 'Photoframe isn\'t ready yet\n\nPlease direct your webbrowser to\n\n'
+                msg += 'http://%s:7777/\n\nand add one or more photo providers' % helper.getDeviceIp()
             else:
-                msg = 'Please direct your webbrowser to\n\nhttp://%s:7777/\n\nto complete the setup process' % helper.getDeviceIp()
+                msg = 'Please direct your webbrowser to\n\n'
+                msg += 'http://%s:7777/\n\nto complete the setup process' % helper.getDeviceIp()
                 for svcName, state, additionalInfo in serviceStates:
                     msg += "\n\n"+svcName+": "
                     if state == 'OAUTH':
diff --git a/modules/sysconfig.py b/modules/sysconfig.py
index c7ff972..dc0de9c 100755
--- a/modules/sysconfig.py
+++ b/modules/sysconfig.py
@@ -60,7 +60,7 @@ def _changeConfigFile(key, value):
                 else:
                     os.rename(path.CONFIG_TXT + '.old', path.CONFIG_TXT + '.original')
                 return True
-            except:
+            except Exception:
                 logging.exception('Failed to activate new config.txt, you may need to restore the config.txt')
 
     @staticmethod
@@ -153,7 +153,7 @@ def getHTTPAuth():
                             user = None
                         else:
                             break
-                except:
+                except Exception:
                     logging.exception('Unable to load JSON from "%s"' % userfile)
                     user = None
         return user
@@ -162,7 +162,7 @@ def getHTTPAuth():
     def setHostname(name):
         # First, make sure it's legal
         name = re.sub(' ', '-', name.strip())
-        name = re.sub('[^a-zA-Z0-9\-]', '', name).strip()
+        name = re.sub('[^a-zA-Z0-9\\-]', '', name).strip()
         if not name or len(name) > 63:
             return False
 
@@ -198,7 +198,7 @@ def setHostname(name):
             except subprocess.CalledProcessError:
                 logging.exception('Couldnt restart avahi, not a deal breaker')
             return True
-        except:
+        except Exception:
             logging.exception('Failed to activate new hostname, you should probably reboot to restore')
         return False
 
diff --git a/modules/timekeeper.py b/modules/timekeeper.py
index 71af1d8..dcdccda 100755
--- a/modules/timekeeper.py
+++ b/modules/timekeeper.py
@@ -100,10 +100,28 @@ def sensorListener(self, temperature, lux):
     def evaluatePower(self):
         # Either source can turn off display but scheduleOff takes priority on power on
         # NOTE! Schedule and sensor can be overriden
-        if not self.standby and ((not self.ignoreSchedule and self.scheduleOff) or (not self.ignoreSensor and self.ambientOff)):
+        if (
+            not self.standby
+            and (
+                (
+                    not self.ignoreSchedule and self.scheduleOff
+                )
+                or (
+                    not self.ignoreSensor and self.ambientOff
+                )
+            )
+        ):
             self.standby = True
             self.notifyListeners(False)
-        elif self.standby and (self.ignoreSchedule or not self.scheduleOff) and (self.ignoreSensor or not self.ambientOff):
+        elif (
+            self.standby
+            and (
+                self.ignoreSchedule or not self.scheduleOff
+            )
+            and (
+                self.ignoreSensor or not self.ambientOff
+            )
+        ):
             self.standby = False
             self.notifyListeners(True)
 
diff --git a/routes/debug.py b/routes/debug.py
index 9abf59e..0e6a97d 100644
--- a/routes/debug.py
+++ b/routes/debug.py
@@ -35,11 +35,15 @@ def handle(self, app, **kwargs):
         report.append(debug.stacktrace())
 
         message = 'Photoframe Log Report'
-        message = '''

Photoframe Log report

''' + message += '

Photoframe Log report

This page is intended ' + message += 'to be used when you run into issues which cannot be resolved by the messages displayed on the ' + message += 'frame. Please save and attach this information when you ' + message += 'create a new issue.' + message += '

Thank you for helping making this project better 😅
' + for item in report: - message += '

%s

' % item[
-                0]
+            message += '

%s

' % item[0] + message += '
'
             if item[1]:
                 for line in item[1]:
                     message += line + '\n'
diff --git a/routes/details.py b/routes/details.py
index 551bb0d..e63a571 100755
--- a/routes/details.py
+++ b/routes/details.py
@@ -73,14 +73,14 @@ def handle(self, app, about):
             output = ''
             try:
                 output = subprocess.check_output(['/opt/vc/bin/vcgencmd', 'get_throttled'], stderr=self.void)
-            except:
+            except Exception:
                 logging.exception('Unable to execute /opt/vc/bin/vcgencmd')
             if not output.startswith('throttled='):
                 logging.error('Output from vcgencmd get_throttled has changed')
                 output = 'throttled=0x0'
             try:
                 h = int(output[10:].strip(), 16)
-            except:
+            except Exception:
                 logging.exception('Unable to convert output from vcgencmd get_throttled')
             result = {
                 'undervoltage': h & (1 << 0 | 1 << 16) > 0,
@@ -96,8 +96,12 @@ def handle(self, app, about):
             timeneeded = images * self.settings.getUser('interval')
             timeavailable = self.settings.getUser('refresh') * 3600
             if timeavailable > 0 and timeneeded > timeavailable:
-                msgs.append({'level': 'WARNING', 'message': 'Change every %d seconds with %d images will take %dh, refresh keywords is %dh' % (
-                    self.settings.getUser('interval'), images, timeneeded/3600, timeavailable/3600), 'link': None})
+                msgs.append({
+                    'level': 'WARNING',
+                    'message': 'Change every %d seconds with %d images will take %dh, refresh keywords is %dh' % (
+                        self.settings.getUser('interval'), images, timeneeded/3600, timeavailable/3600
+                    ),
+                    'link': None})
 
             return self.jsonify(msgs)
         self.setAbort(404)
diff --git a/routes/upload.py b/routes/upload.py
index 0edc475..c9978ea 100644
--- a/routes/upload.py
+++ b/routes/upload.py
@@ -67,7 +67,7 @@ def handle(self, app, item):
 
         try:
             os.remove(filename)
-        except:
+        except Exception:
             pass
         if retval['status'] == 200:
             return self.jsonify(retval['return'])

From 09d03609e505ce43aeef58c1ae7e8fb83fa01024 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Sun, 21 Feb 2021 22:14:40 -0800
Subject: [PATCH 04/20] Fix travis validation script

---
 .travis.yml        | 2 +-
 travis/validate.sh | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/.travis.yml b/.travis.yml
index 379f5cc..ccff706 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,5 +3,5 @@ dist: xenial
 before_install:
  - sudo apt-get update
 install:
-- sudo apt-get install pyflakes
+- sudo apt-get install flake8
 script: "./travis/validate.sh"
diff --git a/travis/validate.sh b/travis/validate.sh
index d65a474..cc5f7e0 100755
--- a/travis/validate.sh
+++ b/travis/validate.sh
@@ -1,4 +1,4 @@
 #!/bin/bash
 
-pyflakes .
+flake8
 exit $?

From 82af88638ecd303615f24bab936e9c46a2072e83 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Mon, 22 Feb 2021 08:06:37 -0800
Subject: [PATCH 05/20] Python3 improvements

---
 MIGRATION.md                 |  1 +
 modules/debug.py             |  2 +-
 modules/sysconfig.py         |  2 +-
 services/svc_googlephotos.py | 21 +++++++++++++--------
 services/svc_simpleurl.py    |  2 +-
 5 files changed, 17 insertions(+), 11 deletions(-)

diff --git a/MIGRATION.md b/MIGRATION.md
index f6f15a9..79ec527 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,2 +1,3 @@
 sudo apt install apt-utils git fbset python3-requests python3-requests-oauthlib python3-flask python3-flask-httpauth imagemagick python3-smbus bc
 
+pip3 install requests requests-oauthlib flask flask-httpauth smbus
diff --git a/modules/debug.py b/modules/debug.py
index d350bbd..cee6fd5 100755
--- a/modules/debug.py
+++ b/modules/debug.py
@@ -38,7 +38,7 @@ def subprocess_call(cmds, stderr=None, stdout=None):
 
 
 def subprocess_check_output(cmds, stderr=None):
-    return subprocess.check_output(cmds, stderr=stderr)
+    return subprocess.check_output(cmds, stderr=stderr).decode("utf-8")
 
 
 def stacktrace():
diff --git a/modules/sysconfig.py b/modules/sysconfig.py
index dc0de9c..c2167df 100755
--- a/modules/sysconfig.py
+++ b/modules/sysconfig.py
@@ -146,7 +146,7 @@ def getHTTPAuth():
             if os.path.exists(userfile):
                 logging.debug('Found "%s", loading the data' % userfile)
                 try:
-                    with open(userfile, 'rb') as f:
+                    with open(userfile, 'r') as f:
                         user = json.load(f)
                         if 'user' not in user or 'password' not in user:
                             logging.warning("\"%s\" doesn't contain a user and password key" % userfile)
diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py
index 7742dd6..0310ecf 100755
--- a/services/svc_googlephotos.py
+++ b/services/svc_googlephotos.py
@@ -391,6 +391,7 @@ def getImagesFor(self, keyword, rawReturn=False):
         # Now try loading
         if os.path.exists(filename):
             try:
+                print(filename)
                 with open(filename, 'r') as f:
                     albumdata = json.load(f)
             except Exception:
@@ -415,14 +416,18 @@ def parseAlbumInfo(self, data, keyword):
             return None
         parsedImages = []
         for entry in data:
-            item = BaseService.createImageHolder(self)
-            item.setId(entry['id'])
-            item.setSource(entry['productUrl']).setMimetype(entry['mimeType'])
-            item.setDimensions(entry['mediaMetadata']['width'], entry['mediaMetadata']['height'])
-            item.allowCache(True)
-            item.setContentProvider(self)
-            item.setContentSource(keyword)
-            parsedImages.append(item)
+            try:
+                item = BaseService.createImageHolder(self)
+                item.setId(entry['id'])
+                item.setSource(entry['productUrl']).setMimetype(entry['mimeType'])
+                item.setDimensions(entry['mediaMetadata']['width'], entry['mediaMetadata']['height'])
+                item.allowCache(True)
+                item.setContentProvider(self)
+                item.setContentSource(keyword)
+                parsedImages.append(item)
+            except Exception:
+                logging.exception('Entry could not be loaded')
+                logging.debug('Contents of entry: ' + repr(entry))
         return parsedImages
 
     def getContentUrl(self, image, hints):
diff --git a/services/svc_simpleurl.py b/services/svc_simpleurl.py
index f68b0b0..17390b1 100755
--- a/services/svc_simpleurl.py
+++ b/services/svc_simpleurl.py
@@ -85,7 +85,7 @@ def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize,
         if retry > 0:
             return self.selectImageFromAlbum(destinationDir, supportedMimeTypes, displaySize, randomize, retry=retry-1)
         return BaseService.createImageHolder(self) \
-            .setError(f'{self.SERVICE_NAME} uses broken urls / unsupported images!')
+            .setError('%s uses broken urls / unsupported images!' % self.SERVICE_NAME)
 
     def getImagesFor(self, keyword):
         url = keyword

From 27865ec2bea4869680c2ff3bd04ce4f93ae7074a Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Mon, 22 Feb 2021 08:12:26 -0800
Subject: [PATCH 06/20] Manually merge last commit from master

---
 modules/slideshow.py         | 2 +-
 services/svc_googlephotos.py | 5 ++++-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/modules/slideshow.py b/modules/slideshow.py
index a5c1642..33b5654 100755
--- a/modules/slideshow.py
+++ b/modules/slideshow.py
@@ -121,7 +121,7 @@ def handleEvents(self):
 
             if event == 'memoryForget' or event == 'clearCache':
                 if event == 'memoryForget':
-                    self.services.memoryForget()
+                    self.services.memoryForgetAll()
                 if event == 'clearCache':
                     self.cacheMgr.empty()
                 if self.imageCurrent:
diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py
index 0310ecf..39ba51a 100755
--- a/services/svc_googlephotos.py
+++ b/services/svc_googlephotos.py
@@ -26,6 +26,7 @@
 class GooglePhotos(BaseService):
     SERVICE_NAME = 'GooglePhotos'
     SERVICE_ID = 2
+    MAX_ITEMS = 8000
 
     def __init__(self, configDir, id, name):
         BaseService.__init__(self, configDir, id, name, needConfig=False, needOAuth=True)
@@ -361,7 +362,7 @@ def getImagesFor(self, keyword, rawReturn=False):
                         .setError('Unable to get photos using keyword "%s"' % keyword)]
 
             url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search'
-            maxItems = 1000  # Should be configurable
+            maxItems = GooglePhotos.MAX_ITEMS # Should be configurable
 
             while len(result) < maxItems:
                 data = self.requestUrl(url, data=params, usePost=True)
@@ -417,6 +418,8 @@ def parseAlbumInfo(self, data, keyword):
         parsedImages = []
         for entry in data:
             try:
+                if entry['mimeType'] not in helper.getSupportedTypes():
+                    continue
                 item = BaseService.createImageHolder(self)
                 item.setId(entry['id'])
                 item.setSource(entry['productUrl']).setMimetype(entry['mimeType'])

From 45e4e62c49e5eb6c7a25d2f941b0814ff262df13 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Mon, 22 Feb 2021 08:19:48 -0800
Subject: [PATCH 07/20] Runs, but probably still bugs

---
 MIGRATION.md      |  1 +
 modules/helper.py | 11 ++++++-----
 modules/images.py |  2 +-
 3 files changed, 8 insertions(+), 6 deletions(-)

diff --git a/MIGRATION.md b/MIGRATION.md
index 79ec527..288d72b 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,3 +1,4 @@
 sudo apt install apt-utils git fbset python3-requests python3-requests-oauthlib python3-flask python3-flask-httpauth imagemagick python3-smbus bc
 
 pip3 install requests requests-oauthlib flask flask-httpauth smbus
+pip3 install netifaces
diff --git a/modules/helper.py b/modules/helper.py
index 2acc006..c580d56 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -21,6 +21,7 @@
 import re
 import random
 import time
+from . import debug
 
 # A regular expression to determine whether a url is valid or not
 # (e.g. "www.example.de/someImg.jpg" is missing "http://")
@@ -64,7 +65,7 @@ def getWeightedRandomIndex(weights):
     @staticmethod
     def getResolution():
         res = None
-        output = subprocess.check_output(['/bin/fbset'], stderr=subprocess.DEVNULL)
+        output = debug.subprocess_check_output(['/bin/fbset'], stderr=subprocess.DEVNULL)
         for line in output.split('\n'):
             line = line.strip()
             if line.startswith('mode "'):
@@ -125,7 +126,7 @@ def getMimetype(filename):
         cmd = ["/usr/bin/file", "--mime", filename]
         with open(os.devnull, 'wb') as void:
             try:
-                output = subprocess.check_output(cmd, stderr=void).strip("\n")
+                output = debug.subprocess_check_output(cmd, stderr=void).strip("\n")
                 m = re.match('[^\\:]+\\: *([^;]+)', output)
                 if m:
                     mimetype = m.group(1)
@@ -155,7 +156,7 @@ def scaleImage(orgFilename, newFilename, newSize):
         ]
 
         try:
-            subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+            debug.subprocess_check_output(cmd, stderr=subprocess.STDOUT)
         except subprocess.CalledProcessError as e:
             logging.exception('Unable to reframe the image')
             logging.error('Output: %s' % repr(e.output))
@@ -170,7 +171,7 @@ def getImageSize(filename):
 
         with open(os.devnull, 'wb') as void:
             try:
-                output = subprocess.check_output(['/usr/bin/identify', filename], stderr=void)
+                output = debug.subprocess_check_output(['/usr/bin/identify', filename], stderr=void)
             except Exception:
                 logging.exception('Failed to run identify to get image dimensions on %s', filename)
                 return None
@@ -336,7 +337,7 @@ def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoCho
             return filename
 
         try:
-            subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+            debug.subprocess_check_output(cmd, stderr=subprocess.STDOUT)
         except subprocess.CalledProcessError as e:
             logging.exception('Unable to reframe the image')
             logging.error('Output: %s' % repr(e.output))
diff --git a/modules/images.py b/modules/images.py
index 7ebefc7..3bb791f 100755
--- a/modules/images.py
+++ b/modules/images.py
@@ -88,7 +88,7 @@ def allowCache(self, allow):
     def getCacheId(self):
         if self.id is None:
             return None
-        return hashlib.sha1(self.id).hexdigest()
+        return hashlib.sha1(self.id.encode('utf-8')).hexdigest()
 
     def copy(self):
         copy = ImageHolder()

From 3ca2f5a3daf742d2b6793b65fcc84f5f7c443786 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Mon, 22 Feb 2021 11:19:05 -0800
Subject: [PATCH 08/20] Fix byte vs str for subprocess calls

---
 frame.py                     |  4 +++-
 modules/drivers.py           |  4 ++--
 modules/helper.py            |  8 ++++----
 modules/server.py            |  5 +++--
 modules/sysconfig.py         |  4 ++--
 routes/baseroute.py          |  3 ++-
 routes/details.py            |  8 ++++----
 services/svc_googlephotos.py |  8 ++++----
 services/svc_usb.py          | 12 ++++++------
 9 files changed, 30 insertions(+), 26 deletions(-)

diff --git a/frame.py b/frame.py
index a57be1e..9b9a6c9 100755
--- a/frame.py
+++ b/frame.py
@@ -73,6 +73,8 @@ def __init__(self, cmdline):
         if not path().validate():
             sys.exit(255)
 
+        self.debugmode = cmdline.debug
+
         self.eventMgr = Events()
         self.eventMgr.add('Hello world')
 
@@ -133,7 +135,7 @@ def _loadRoute(self, module, klass, *vargs):
         self.webServer.registerHandler(route)
 
     def setupWebserver(self, listen, port):
-        test = WebServer(port=port, listen=listen)
+        test = WebServer(port=port, listen=listen, debug=self.debugmode)
         self.webServer = test
 
         self._loadRoute('settings', 'RouteSettings', self.powerMgr, self.settingsMgr, self.driverMgr,
diff --git a/modules/drivers.py b/modules/drivers.py
index 2892c8b..775dff2 100644
--- a/modules/drivers.py
+++ b/modules/drivers.py
@@ -21,7 +21,7 @@
 import json
 
 from modules.path import path
-
+from . import debug
 
 class drivers:
     MARKER = '### DO NOT EDIT BEYOND THIS COMMENT, IT\'S AUTOGENERATED BY PHOTOFRAME ###'
@@ -147,7 +147,7 @@ def install(self, file):
         folder = tempfile.mkdtemp()
         extra, _ = os.path.basename(file).rsplit('.', 1)  # This is to make sure we have a foldername
         try:
-            result = subprocess.check_call(
+            result = debug.subprocess_check_call(
                 ['/usr/bin/unzip', file, '-d', os.path.join(folder, extra)], stdout=self.void, stderr=self.void)
         except Exception:
             result = 255
diff --git a/modules/helper.py b/modules/helper.py
index c580d56..b7dd2b2 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -347,7 +347,7 @@ def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoCho
 
     @staticmethod
     def timezoneList():
-        zones = subprocess.check_output(['/usr/bin/timedatectl', 'list-timezones']).split('\n')
+        zones = debug.subprocess_check_output(['/usr/bin/timedatectl', 'list-timezones']).split('\n')
         return [x for x in zones if x]
 
     @staticmethod
@@ -361,7 +361,7 @@ def timezoneSet(zone):
         result = 1
         try:
             with open(os.devnull, 'wb') as void:
-                result = subprocess.check_call(['/usr/bin/timedatectl', 'set-timezone', zone], stderr=void)
+                result = debug.subprocess_check_call(['/usr/bin/timedatectl', 'set-timezone', zone], stderr=void)
         except Exception:
             logging.exception('Unable to change timezone')
             pass
@@ -401,7 +401,7 @@ def autoRotate(ifile):
         parameters = ['', '-flip horizontal', '-rotate 180', '-flip vertical',
                       '-transpose', '-rotate 90', '-transverse', '-rotate 270']
         with open(os.devnull, 'wb') as void:
-            result = subprocess.check_output(['/usr/bin/jpegexiforient', ifile])  # , stderr=void)
+            result = debug.subprocess_check_output(['/usr/bin/jpegexiforient', ifile])  # , stderr=void)
         if result:
             orient = int(result)-1
             if orient < 0 or orient >= len(parameters):
@@ -411,7 +411,7 @@ def autoRotate(ifile):
             cmd.extend(parameters[orient].split())
             cmd.extend(['-outfile', ofile, ifile])
             with open(os.devnull, 'wb') as void:
-                result = subprocess.check_call(cmd, stderr=void)
+                result = debug.subprocess_check_call(cmd, stderr=void)
             if result == 0:
                 os.unlink(ifile)
                 return ofile
diff --git a/modules/server.py b/modules/server.py
index ad3a186..6ba5188 100755
--- a/modules/server.py
+++ b/modules/server.py
@@ -42,11 +42,12 @@ def wrap(*args, **kwargs):
 
 
 class WebServer(Thread):
-    def __init__(self, run_async=False, port=7777, listen='0.0.0.0'):
+    def __init__(self, run_async=False, port=7777, listen='0.0.0.0', debug=False):
         Thread.__init__(self)
         self.port = port
         self.listen = listen
         self.run_async = run_async
+        self.debug = debug
 
         self.app = Flask(__name__, static_url_path='/--do--not--ever--use--this--')
         self.app.config['UPLOAD_FOLDER'] = '/tmp/'
@@ -101,7 +102,7 @@ def stop(self):
 
     def run(self):
         try:
-            self.app.run(debug=False, port=self.port, host=self.listen)
+            self.app.run(debug=self.debug, use_reloader=False, port=self.port, host=self.listen)
         except RuntimeError as msg:
             if str(msg) == "Server shutdown":
                 pass  # or whatever you want to do when the server goes down
diff --git a/modules/sysconfig.py b/modules/sysconfig.py
index c2167df..d218928 100755
--- a/modules/sysconfig.py
+++ b/modules/sysconfig.py
@@ -20,7 +20,7 @@
 
 from .path import path
 import logging
-
+from . import debug
 
 class sysconfig:
     @staticmethod
@@ -189,7 +189,7 @@ def setHostname(name):
 
             # also, run hostname with the new name
             with open(os.devnull, 'wb') as void:
-                subprocess.check_call(['/bin/hostname', name], stderr=void)
+                debug.subprocess_check_call(['/bin/hostname', name], stderr=void)
 
             # Final step, restart avahi (so it knows the correct hostname)
             try:
diff --git a/routes/baseroute.py b/routes/baseroute.py
index 76baa71..7454cb3 100644
--- a/routes/baseroute.py
+++ b/routes/baseroute.py
@@ -60,7 +60,8 @@ def setup(self):
         pass
 
     def __call__(self, **kwargs):
-        return self.handle(self.app, **kwargs)
+        ret = self.handle(self.app, **kwargs)
+        return ret
 
     def handle(self, app, **kwargs):
         msg = '%s does not have an implementation' % self._URL
diff --git a/routes/details.py b/routes/details.py
index e63a571..44a0fba 100755
--- a/routes/details.py
+++ b/routes/details.py
@@ -20,7 +20,7 @@
 from modules.helper import helper
 
 from .baseroute import BaseRoute
-
+from modules import debug
 
 class RouteDetails(BaseRoute):
     def setupex(self, displaymgr, drivermgr, colormatch, slideshow, servicemgr, settings):
@@ -53,11 +53,11 @@ def handle(self, app, about):
             result = helper.timezoneList()
             return self.jsonify(result)
         elif about == 'version':
-            output = subprocess.check_output(['git', 'log', '-n1'], stderr=self.void)
+            output = debug.subprocess_check_output(['git', 'log', '-n1'], stderr=self.void)
             lines = output.split('\n')
             infoDate = lines[2][5:].strip()
             infoCommit = lines[0][7:].strip()
-            output = subprocess.check_output(['git', 'status'], stderr=self.void)
+            output = debug.subprocess_check_output(['git', 'status'], stderr=self.void)
             lines = output.split('\n')
             infoBranch = lines[0][10:].strip()
             return self.jsonify({'date': infoDate, 'commit': infoCommit, 'branch': infoBranch})
@@ -72,7 +72,7 @@ def handle(self, app, about):
         elif about == 'hardware':
             output = ''
             try:
-                output = subprocess.check_output(['/opt/vc/bin/vcgencmd', 'get_throttled'], stderr=self.void)
+                output = debug.subprocess_check_output(['/opt/vc/bin/vcgencmd', 'get_throttled'], stderr=self.void)
             except Exception:
                 logging.exception('Unable to execute /opt/vc/bin/vcgencmd')
             if not output.startswith('throttled='):
diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py
index 39ba51a..2bf323c 100755
--- a/services/svc_googlephotos.py
+++ b/services/svc_googlephotos.py
@@ -278,7 +278,7 @@ def translateKeywordToId(self, keyword):
             data = self.requestUrl(url, params=params)
             if not data.isSuccess():
                 return None
-            data = json.loads(data.content)
+            data = json.loads(data.content.encode('utf-8'))
             for i in range(len(data['albums'])):
                 if 'title' in data['albums'][i]:
                     logging.debug('Album: %s' % data['albums'][i]['title'])
@@ -301,7 +301,7 @@ def translateKeywordToId(self, keyword):
                 data = self.requestUrl(url, params=params)
                 if not data.isSuccess():
                     return None
-                data = json.loads(data.content)
+                data = json.loads(data.content.encode('utf-8'))
                 if 'sharedAlbums' not in data:
                     logging.debug('User has no shared albums')
                     break
@@ -371,7 +371,7 @@ def getImagesFor(self, keyword, rawReturn=False):
                     logging.warning('More details: ' + repr(data.content))
                     break
                 else:
-                    data = json.loads(data.content)
+                    data = json.loads(data.content.encode('utf-8'))
                     if 'mediaItems' not in data:
                         break
                     logging.debug('Got %d entries, adding it to existing %d entries',
@@ -440,7 +440,7 @@ def getContentUrl(self, image, hints):
             logging.error('%d,%d: Failed to get URL', data.httpcode, data.result)
             return None
 
-        data = json.loads(data.content)
+        data = json.loads(data.content.encode('utf-8'))
         if 'baseUrl' not in data:
             logging.error('Data from Google didn\'t contain baseUrl, see original content:')
             logging.error(repr(data))
diff --git a/services/svc_usb.py b/services/svc_usb.py
index 8581804..707bbd4 100755
--- a/services/svc_usb.py
+++ b/services/svc_usb.py
@@ -22,7 +22,7 @@
 
 from modules.helper import helper
 from modules.network import RequestResult
-
+from modules import debug
 
 class USB_Photos(BaseService):
     SERVICE_NAME = 'USB-Photos'
@@ -203,7 +203,7 @@ def detectAllStorageDevices(self, onlyMounted=False, onlyUnmounted=False, revers
         candidates = []
         for root, dirs, files in os.walk('/sys/block/'):
             for device in dirs:
-                result = subprocess.check_output(['udevadm', 'info', '--query=property', '/sys/block/' + device])
+                result = debug.subprocess_check_output(['udevadm', 'info', '--query=property', '/sys/block/' + device])
                 if result and 'ID_BUS=usb' in result:
                     values = {}
                     for line in result.split('\n'):
@@ -214,7 +214,7 @@ def detectAllStorageDevices(self, onlyMounted=False, onlyUnmounted=False, revers
                         values[k] = v
                     if 'DEVNAME' in values:
                         # Now, locate the relevant partition
-                        result = subprocess.check_output(['lsblk', '-bOJ', values['DEVNAME']])
+                        result = debug.subprocess_check_output(['lsblk', '-bOJ', values['DEVNAME']])
                         if result is not None:
                             partitions = json.loads(result)['blockdevices'][0]['children']
                             for partition in partitions:
@@ -245,7 +245,7 @@ def mountStorageDevice(self):
         if not os.path.exists(self.usbDir):
             cmd = ["mkdir", self.usbDir]
             try:
-                subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+                debug.subprocess_check_output(cmd, stderr=subprocess.STDOUT)
             except subprocess.CalledProcessError as e:
                 logging.exception('Unable to create directory: %s' % cmd[-1])
                 logging.error('Output: %s' % repr(e.output))
@@ -256,7 +256,7 @@ def mountStorageDevice(self):
         for candidate in candidates:
             cmd = ['sudo', '-n', 'mount', candidate.device, self.usbDir]
             try:
-                subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+                debug.subprocess_check_output(cmd, stderr=subprocess.STDOUT)
                 logging.info("USB-device '%s' successfully mounted to '%s'!" % (cmd[-2], cmd[-1]))
                 if os.path.exists(self.baseDir):
                     self.device = candidate
@@ -273,7 +273,7 @@ def mountStorageDevice(self):
     def unmountBaseDir(self):
         cmd = ['sudo', '-n', 'umount', self.usbDir]
         try:
-            subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+            debug.subprocess_check_output(cmd, stderr=subprocess.STDOUT)
         except subprocess.CalledProcessError:
             logging.debug("unable to UNMOUNT '%s'" % self.usbDir)
 

From 4c295b33e5c96026f2dfeeca55094c28d8f42011 Mon Sep 17 00:00:00 2001
From: Tom Anschutz 
Date: Tue, 23 Feb 2021 21:54:13 -0500
Subject: [PATCH 09/20] Update MIGRATION.md (#178)

I took the 2019-01-05 image, freshly installed and upgraded to a clean current version on RPi3B.  Then tried the instructions.   pip3 needed to be installed, and in this distro, python3-flask-httpauth was not a separate package.   I thought it might be missing and tried installing with pip3, but it claimed that it was already there from the dist-packages.    BTW, do you have a flag for the update script to select a branch, or just use git --branch ?
---
 MIGRATION.md | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/MIGRATION.md b/MIGRATION.md
index 288d72b..75f87c8 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,4 +1,9 @@
-sudo apt install apt-utils git fbset python3-requests python3-requests-oauthlib python3-flask python3-flask-httpauth imagemagick python3-smbus bc
+Manual steps to try this branch
 
-pip3 install requests requests-oauthlib flask flask-httpauth smbus
-pip3 install netifaces
+    sudo bash
+    cd
+    apt install apt-utils git fbset python3-pip python3-requests python3-requests-oauthlib python3-flask  imagemagick python3-smbus bc
+    
+    pip3 install requests requests-oauthlib flask flask-httpauth smbus
+    pip3 install netifaces
+    

From f6a297bd3e48f669f566897d8d4a1392dfd35e21 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 25 Feb 2021 20:40:50 -0800
Subject: [PATCH 10/20] Wrong method used

---
 services/svc_googlephotos.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py
index 2bf323c..cb1b2df 100755
--- a/services/svc_googlephotos.py
+++ b/services/svc_googlephotos.py
@@ -278,7 +278,7 @@ def translateKeywordToId(self, keyword):
             data = self.requestUrl(url, params=params)
             if not data.isSuccess():
                 return None
-            data = json.loads(data.content.encode('utf-8'))
+            data = json.loads(data.content.decode('utf-8'))
             for i in range(len(data['albums'])):
                 if 'title' in data['albums'][i]:
                     logging.debug('Album: %s' % data['albums'][i]['title'])
@@ -301,7 +301,7 @@ def translateKeywordToId(self, keyword):
                 data = self.requestUrl(url, params=params)
                 if not data.isSuccess():
                     return None
-                data = json.loads(data.content.encode('utf-8'))
+                data = json.loads(data.content.decode('utf-8'))
                 if 'sharedAlbums' not in data:
                     logging.debug('User has no shared albums')
                     break
@@ -371,7 +371,7 @@ def getImagesFor(self, keyword, rawReturn=False):
                     logging.warning('More details: ' + repr(data.content))
                     break
                 else:
-                    data = json.loads(data.content.encode('utf-8'))
+                    data = json.loads(data.content.decode('utf-8'))
                     if 'mediaItems' not in data:
                         break
                     logging.debug('Got %d entries, adding it to existing %d entries',
@@ -440,7 +440,7 @@ def getContentUrl(self, image, hints):
             logging.error('%d,%d: Failed to get URL', data.httpcode, data.result)
             return None
 
-        data = json.loads(data.content.encode('utf-8'))
+        data = json.loads(data.content.decode('utf-8'))
         if 'baseUrl' not in data:
             logging.error('Data from Google didn\'t contain baseUrl, see original content:')
             logging.error(repr(data))

From 451203bae1ed8c2d3fb5b53dbae46a8306586ea2 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 25 Feb 2021 21:48:07 -0800
Subject: [PATCH 11/20] Initial version of the next kind of updating service

---
 update_ng.sh            | 196 ++++++++++++++++++++++++++++++++++++++++
 updates/000_Old_updates |  20 ++++
 updates/001_example     |   6 ++
 3 files changed, 222 insertions(+)
 create mode 100755 update_ng.sh
 create mode 100644 updates/000_Old_updates
 create mode 100644 updates/001_example

diff --git a/update_ng.sh b/update_ng.sh
new file mode 100755
index 0000000..91077d1
--- /dev/null
+++ b/update_ng.sh
@@ -0,0 +1,196 @@
+#!/bin/bash
+#
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe.  If not, see .
+#
+# Update NG (ie, 2.0)
+#
+# Works differently than the old one, it will allow future versions to
+# use individual files and then track them once done.
+#
+# Flow:
+#   1. Check new version
+#   2. Kill frame.py (do not stop service, it will kill us too)
+#   3. Perform git pull
+#   4. Process any new update scripts
+#   5. Restart frame service
+#
+# If we get interrupted in step 4, reboot will cause remaining steps to be executed
+#
+# To run this in a test environment, you can override the use of /root by exporting
+# STORAGE set to a path you have write access to.
+#
+if [ -z ${STORAGE} ]; then
+    STORAGE="/root"
+else
+    echo >&2 "WARNING: Using ${STORAGE} instead of /root"
+fi
+
+BASEDIR="$(dirname "$0")" # Where we are
+GITLOG="/tmp/update.log" # Where to store the log
+
+UPDATEDIR="${BASEDIR}/updates/" # Where to find updates
+UPDATELOG="${STORAGE}/.update_done" # Tracks which updates we've applied
+UPDATEINIT="${STORAGE}/.firstupdate" # If we ever ran
+UPDATEPOST="/tmp/photoframe_post_update.done" # If this file is missing, will rerun "post" mode to catch any missing
+
+function error
+{
+	echo >&2 "error: $1"
+	if [ -f ${GITLOG} ]; then
+		cat >&2 ${GITLOG}
+        echo >&2 "=== This logfile is located at ${GITLOG} ==="
+	fi
+	exit 255
+}
+
+function has_update
+{
+	# See if we have changes locally or commits locally (because then we cannot update)
+	if git status | egrep '(not staged|Untracked|ahead|to be committed)' >/dev/null; then
+		error "Unable to update due to local changes"
+	fi
+
+	BRANCH="$(git status | head -n1)" ; BRANCH=${BRANCH:10}
+	git fetch 2>&1 >${GITLOG} || error "Unable to load info about latest"
+	git log -n1 --oneline origin/${BRANCH} >/tmp/server.txt
+	git log -n1 --oneline >/tmp/client.txt
+
+	local RET=1
+	if ! diff /tmp/server.txt /tmp/client.txt >/dev/null ; then
+		RET=0
+	fi
+
+	rm /tmp/server.txt 2>/dev/null 1>/dev/null
+	rm /tmp/client.txt 2>/dev/null 1>/dev/null
+	return ${RET}
+}
+
+function perform_update
+{
+    # Show updating message
+    PID=$(pgrep -f frame.py)
+
+    # Do NOT kill the service itself, since it will actually tear down the python script
+    kill -SIGHUP $PID 2>/dev/null
+
+    echo "New version is available (for branch ${BRANCH})"
+    git pull --rebase >>${GITLOG} 2>&1 || error "Unable to update"
+
+    # Run again with the post option so any necessary changes can be carried out
+    ${BASEDIR}/update.sh post
+
+    # Always refresh our services by default since you never know
+    cp ${BASEDIR}/frame.service /etc/systemd/system/
+    systemctl daemon-reload
+
+    # Skip service restart if we were running an update only
+    if [ "$1" != "updateonly" ]; then
+        systemctl restart frame.service
+    fi
+}
+
+function track_first_run
+{
+    # Mark this has being done
+    touch ${UPDATEINIT}
+}
+
+function track_post_done
+{
+    # Mark that we did post update stuff
+    touch ${UPDATEPOST}
+}
+
+function has_post_done
+{
+    if [ ! -f ${UPDATEPOST} ]; then
+        return 1
+    fi
+    return 0
+}
+
+function perform_post_update
+{
+    # This is the magic, we now use a file to track what we've done.
+    # Once an update has been done, the script is logged.
+
+    if [ ! -d ${UPDATEDIR} ]; then
+        error "Directory with updates is missing (${UPDATEDIR})"
+    fi
+
+    # Avoids us failing for missing file
+    touch ${UPDATELOG}
+    local LST_DONE=$(cat ${UPDATELOG})
+    local LST_AVAILABLE=$(ls -1 ${UPDATEDIR})
+    local UPDATE
+    local I
+    local DONE=false
+
+    for UPDATE in ${LST_AVAILABLE} ; do
+        DONE=false
+        for I in ${LST_DONE} ; do
+            if [ "${I}" = "${UPDATE}" ]; then
+                DONE=true
+                break
+            fi
+        done
+        if ${DONE}; then
+            continue
+        fi
+        echo "Applying ${UPDATE}"
+        (
+            source ${UPDATEDIR}/${UPDATE}
+        )
+        if [ $? -ne 0 ]; then
+            # Would be nice if we could do something with this
+            # For now, let's just log it at least. In the future, we may render something to the screen if we can
+            echo >&2 "WARNING: ${UPDATE} failed to apply"
+        fi
+        echo >>${UPDATELOG} "${UPDATE}"
+    done
+    track_post_done
+}
+
+cd ${BASEDIR}
+
+# ALWAYS make sure update completed last time
+if ! has_post_done; then
+    echo >&2 "INFO: Cannot detect post run from last update, making sure all updates are applied before continuing"
+    perform_post_update
+fi
+
+if [ "$1" = "checkversion" ]; then
+	if has_update; then
+		# Return non-zero on new version
+		exit 1
+	fi
+	exit 0
+elif [ "$1" = "post" ]; then
+    # Do post update things, do not call this manually, intended to be used by
+    # this script itself.
+    perform_post_update
+elif has_update ; then
+    perform_update
+    track_first_run
+else
+    echo "No new version available"
+    track_first_run
+fi
+
+# Remove any potential left-over log at this time
+if [ -f ${GITLOG} ]; then
+    rm ${GITLOG}
+fi
diff --git a/updates/000_Old_updates b/updates/000_Old_updates
new file mode 100644
index 0000000..09d1e2b
--- /dev/null
+++ b/updates/000_Old_updates
@@ -0,0 +1,20 @@
+# Original update file, do not change
+############
+
+# Due to older version not enabling the necessary parts,
+# we need to add i2c-dev to modules if not there
+if ! grep "i2c-dev" /etc/modules-load.d/modules.conf >/dev/null ; then
+    echo "i2c-dev" >> /etc/modules-load.d/modules.conf
+    modprobe i2c-dev
+fi
+
+# Make sure all old files are moved into the new config folder
+mkdir /root/photoframe_config >/dev/null 2>/dev/null
+FILES="oauth.json settings.json http_auth.json colortemp.sh"
+for FILE in ${FILES}; do
+    mv /root/${FILE} /root/photoframe_config/ >/dev/null 2>/dev/null
+done
+
+# We also have added more dependencies, so add more software
+apt-get update
+apt-get install -y libjpeg-turbo-progs python-netifaces
diff --git a/updates/001_example b/updates/001_example
new file mode 100644
index 0000000..9b80152
--- /dev/null
+++ b/updates/001_example
@@ -0,0 +1,6 @@
+# This is simply an example of an update file.
+# It's run by source:ing it in a subshell, isolating it from the rest of the
+# update process. It goes without saying that these files should not be executable
+# by themselves.
+#
+echo "Proof of concept, does nothing really"
\ No newline at end of file

From 591012d8099c1f237b84937f6fd51b35a03861dd Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 25 Feb 2021 22:13:19 -0800
Subject: [PATCH 12/20] Fix flake8 errors

---
 modules/drivers.py           |  2 +-
 modules/helper.py            | 11 +++++++++++
 modules/sysconfig.py         |  1 +
 routes/details.py            |  2 +-
 services/svc_googlephotos.py |  2 +-
 services/svc_usb.py          |  1 +
 tox.ini                      |  9 +++++++++
 7 files changed, 25 insertions(+), 3 deletions(-)

diff --git a/modules/drivers.py b/modules/drivers.py
index 775dff2..f3b6e2f 100644
--- a/modules/drivers.py
+++ b/modules/drivers.py
@@ -14,7 +14,6 @@
 # along with photoframe.  If not, see .
 #
 import os
-import subprocess
 import logging
 import tempfile
 import shutil
@@ -23,6 +22,7 @@
 from modules.path import path
 from . import debug
 
+
 class drivers:
     MARKER = '### DO NOT EDIT BEYOND THIS COMMENT, IT\'S AUTOGENERATED BY PHOTOFRAME ###'
 
diff --git a/modules/helper.py b/modules/helper.py
index b7dd2b2..30a970f 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -416,3 +416,14 @@ def autoRotate(ifile):
                 os.unlink(ifile)
                 return ofile
         return ifile
+
+    @staticmethod
+    def subprocess_call(cmds, **kwargs):
+        return subprocess.call(cmds, **kwargs)
+
+    @staticmethod
+    def subprocess_check_output(cmds, decodeData=True, **kwargs):
+        ret = subprocess.check_output(cmds, **kwargs)
+        if decodeData:
+            return ret.decode("utf-8")
+        return ret
diff --git a/modules/sysconfig.py b/modules/sysconfig.py
index d218928..da28c80 100755
--- a/modules/sysconfig.py
+++ b/modules/sysconfig.py
@@ -22,6 +22,7 @@
 import logging
 from . import debug
 
+
 class sysconfig:
     @staticmethod
     def _getConfigFileState(key):
diff --git a/routes/details.py b/routes/details.py
index 44a0fba..4903534 100755
--- a/routes/details.py
+++ b/routes/details.py
@@ -14,7 +14,6 @@
 # along with photoframe.  If not, see .
 #
 import os
-import subprocess
 import logging
 
 from modules.helper import helper
@@ -22,6 +21,7 @@
 from .baseroute import BaseRoute
 from modules import debug
 
+
 class RouteDetails(BaseRoute):
     def setupex(self, displaymgr, drivermgr, colormatch, slideshow, servicemgr, settings):
         self.displaymgr = displaymgr
diff --git a/services/svc_googlephotos.py b/services/svc_googlephotos.py
index cb1b2df..3eed318 100755
--- a/services/svc_googlephotos.py
+++ b/services/svc_googlephotos.py
@@ -362,7 +362,7 @@ def getImagesFor(self, keyword, rawReturn=False):
                         .setError('Unable to get photos using keyword "%s"' % keyword)]
 
             url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search'
-            maxItems = GooglePhotos.MAX_ITEMS # Should be configurable
+            maxItems = GooglePhotos.MAX_ITEMS  # Should be configurable
 
             while len(result) < maxItems:
                 data = self.requestUrl(url, data=params, usePost=True)
diff --git a/services/svc_usb.py b/services/svc_usb.py
index 707bbd4..47069f5 100755
--- a/services/svc_usb.py
+++ b/services/svc_usb.py
@@ -24,6 +24,7 @@
 from modules.network import RequestResult
 from modules import debug
 
+
 class USB_Photos(BaseService):
     SERVICE_NAME = 'USB-Photos'
     SERVICE_ID = 4
diff --git a/tox.ini b/tox.ini
index be62af3..070d4cd 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,3 +4,12 @@ exclude =
     # No need to traverse our git directory
     .git
 
+[tox]
+envlist = py36
+
+[testenv]
+# install pytest in the virtualenv where commands will be executed
+deps = pytest
+commands =
+    # NOTE: you can run any command line tool here - not just tests
+    pytest
\ No newline at end of file

From afebb8189d45ba64c6759a16a4ad6bb1f84dc768 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 25 Feb 2021 22:20:20 -0800
Subject: [PATCH 13/20] More fixes for flake8

---
 modules/server.py     |  1 +
 modules/settings.py   |  6 +++---
 modules/timekeeper.py | 16 ++++++++--------
 3 files changed, 12 insertions(+), 11 deletions(-)

diff --git a/modules/server.py b/modules/server.py
index 6ba5188..fd94691 100755
--- a/modules/server.py
+++ b/modules/server.py
@@ -55,6 +55,7 @@ def __init__(self, run_async=False, port=7777, listen='0.0.0.0', debug=False):
         self.auth = NoAuth()
         if self.user is not None:
             self.auth = HTTPBasicAuth()
+
             @self.auth.get_password
             def check_password(username):
                 if self.user['user'] == username:
diff --git a/modules/settings.py b/modules/settings.py
index 0584b45..e61501c 100755
--- a/modules/settings.py
+++ b/modules/settings.py
@@ -90,9 +90,9 @@ def load(self):
                     # This is a little bit of a cheat
                     parts = self.settings['cfg']['tvservice'].split(' ')
                     if (
-                        len(parts) == 3
-                        and type(self.convertToNative(parts[1])) != int
-                        and type(self.convertToNative(parts[2])) == int
+                        len(parts) == 3 and
+                        type(self.convertToNative(parts[1])) != int and
+                        type(self.convertToNative(parts[2])) == int
                     ):
                         logging.debug('Reordering tvservice value due to old bug')
                         self.settings['cfg']['tvservice'] = "%s %s %s" % (parts[0], parts[2], parts[1])
diff --git a/modules/timekeeper.py b/modules/timekeeper.py
index dcdccda..545ac49 100755
--- a/modules/timekeeper.py
+++ b/modules/timekeeper.py
@@ -101,12 +101,12 @@ def evaluatePower(self):
         # Either source can turn off display but scheduleOff takes priority on power on
         # NOTE! Schedule and sensor can be overriden
         if (
-            not self.standby
-            and (
+            not self.standby and
+            (
                 (
                     not self.ignoreSchedule and self.scheduleOff
-                )
-                or (
+                ) or
+                (
                     not self.ignoreSensor and self.ambientOff
                 )
             )
@@ -114,11 +114,11 @@ def evaluatePower(self):
             self.standby = True
             self.notifyListeners(False)
         elif (
-            self.standby
-            and (
+            self.standby and
+            (
                 self.ignoreSchedule or not self.scheduleOff
-            )
-            and (
+            ) and
+            (
                 self.ignoreSensor or not self.ambientOff
             )
         ):

From ba620644779a702bb327d6bf344ec7b49d4a98c3 Mon Sep 17 00:00:00 2001
From: Tom Anschutz 
Date: Sat, 27 Feb 2021 01:54:09 -0500
Subject: [PATCH 14/20] Update debug.py (#179)

* Update debug.py

Fixes to make "Log Report" button work again

* Update shutdown.py

* Update helper.py

* Update debug.py

Per our discussion with previous changes

* Update helper.py

Changes per our discussion
---
 modules/debug.py    | 12 ++++++++----
 modules/helper.py   |  7 +++----
 modules/shutdown.py | 10 +++++-----
 3 files changed, 16 insertions(+), 13 deletions(-)

diff --git a/modules/debug.py b/modules/debug.py
index cee6fd5..02aa493 100755
--- a/modules/debug.py
+++ b/modules/debug.py
@@ -35,11 +35,13 @@ def _stringify(args):
 
 def subprocess_call(cmds, stderr=None, stdout=None):
     return subprocess.call(cmds, stderr=stderr, stdout=stdout)
-
+    # TODO  Relocate to helper?  Convert to subprocess.run?  Add exception to collect output
+    # in an error log.  Add debug logging option as well?  Add **kwargs
 
 def subprocess_check_output(cmds, stderr=None):
     return subprocess.check_output(cmds, stderr=stderr).decode("utf-8")
-
+    # TODO basically same treatment as suborocess_call.  Although, with using subprocess.run,
+    # check output or not is just another arg.  So, this might just set that arg and subprocess_call.
 
 def stacktrace():
     title = 'Stacktrace of all running threads'
@@ -60,7 +62,8 @@ def logfile(all=False):
     if all:
         title = 'Last 100 lines from the system log (/var/log/syslog)'
         cmd = 'tail -n 100 /var/log/syslog'
-    lines = subprocess.check_output(cmd, shell=True)
+    lines = subprocess.check_output(cmd, shell=True).decode("utf-8")
+    # TODO - convert this to use a common subprocess._check_output dunction
     if lines:
         lines = lines.splitlines()
     suffix = '(size of logfile %d bytes, created %s)' % (stats.st_size,
@@ -70,7 +73,8 @@ def logfile(all=False):
 
 def version():
     title = 'Running version'
-    lines = subprocess.check_output('git log HEAD~1..HEAD ; echo "" ; git status', shell=True)
+    lines = subprocess.check_output('git log HEAD~1..HEAD ; echo "" ; git status', shell=True).decode("utf-8")
+    # TODO - convert this to use a common subprocess_check_output function
     if lines:
         lines = lines.splitlines()
     return (title, lines, None)
diff --git a/modules/helper.py b/modules/helper.py
index 30a970f..b796d9a 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -404,14 +404,13 @@ def autoRotate(ifile):
             result = debug.subprocess_check_output(['/usr/bin/jpegexiforient', ifile])  # , stderr=void)
         if result:
             orient = int(result)-1
-            if orient < 0 or orient >= len(parameters):
-                logging.info('Orientation was %d, not transforming it', orient)
+            if not (0 < orient < len(parameters)):
+                logging.debug('Orientation was %d, not transforming it', orient)
                 return ifile
             cmd = [helper.TOOL_ROTATE]
             cmd.extend(parameters[orient].split())
             cmd.extend(['-outfile', ofile, ifile])
-            with open(os.devnull, 'wb') as void:
-                result = debug.subprocess_check_call(cmd, stderr=void)
+            result = debug.subprocess_call(cmd)
             if result == 0:
                 os.unlink(ifile)
                 return ofile
diff --git a/modules/shutdown.py b/modules/shutdown.py
index 62bde7a..e2adcf5 100644
--- a/modules/shutdown.py
+++ b/modules/shutdown.py
@@ -38,22 +38,22 @@ def run(self):
         # Shutdown can be initated from GPIO26
         poller = select.poll()
         try:
-            with open('/sys/class/gpio/export', 'wb') as f:
+            with open('/sys/class/gpio/export', 'w') as f:
                 f.write('%d' % self.gpio)
         except Exception:
             # Usually it means we ran this before
             pass
         try:
-            with open('/sys/class/gpio/gpio%d/direction' % self.gpio, 'wb') as f:
+            with open('/sys/class/gpio/gpio%d/direction' % self.gpio, 'w') as f:
                 f.write('in')
         except Exception:
             logging.warn('Either no GPIO subsystem or no access')
             return
-        with open('/sys/class/gpio/gpio%d/edge' % self.gpio, 'wb') as f:
+        with open('/sys/class/gpio/gpio%d/edge' % self.gpio, 'w') as f:
             f.write('both')
-        with open('/sys/class/gpio/gpio%d/active_low' % self.gpio, 'wb') as f:
+        with open('/sys/class/gpio/gpio%d/active_low' % self.gpio, 'w') as f:
             f.write('1')
-        with open('/sys/class/gpio/gpio%d/value' % self.gpio, 'rb') as f:
+        with open('/sys/class/gpio/gpio%d/value' % self.gpio, 'r') as f:
             f.read()
             poller.register(f, select.POLLPRI)
             poller.register(self.server, select.POLLHUP)

From f3ace0aa2fdc7b9be1ccce720169f1cc1e2771b5 Mon Sep 17 00:00:00 2001
From: Tom Anschutz 
Date: Tue, 8 Jun 2021 14:51:49 -0400
Subject: [PATCH 15/20] Python3 (#187)

* Update debug.py

Fixes to make "Log Report" button work again

* Update shutdown.py

* Update helper.py

* Update debug.py

Per our discussion with previous changes

* Update helper.py

Changes per our discussion

* Update base.py

Add same logic to python3 branch for issue #182

* Update display.py

Fix for python3 branch for handling images set to Do Nothing or to Fill

* Update servicemanager.py

* Update oauth.py

* Update oauthlink.py

added codecs library and modified code to translate bytes to string for json.load

* Update README.md

Updated to describe this branch specifically.

* Update svc_googlephotos.py

Bring up to date with mrworf master/python3 version

* Update colormatch.py

Added Ability to make use of alternate TCS34727 color sensor on a different module.   This module has a better physical design than the Adafruit module, and works the same, except it has a version ID of 77 (0x4D) instead of 68 (0x44).  It was bought on eBay: https://www.ebay.com/itm/133600154256

* Update README.md

Updated description of alternate color module

* Update helper.py

Added Code to support HEIC and HEIF images.

* Update README.md

Spelling and grammar

* Update helper.py

fix syntax error

* Update helper.py

Indent error for Copy/paste

* Update helper.py

Moved HEIC to JPG conversion from makeFullframe to Autorotate in order to catch a missing case where a HEIC was set to do nothing but changed colors when cropped to fit the screen.

* Update colormatch.py

Colormatch had some questionable logic at low lux levels.  The changes allow temperature to be calculated as long as all the colors are not 0.  And also, when there is NO light, zero temperature is probably not correct, rather make a middle-of-the-road assumption.

* Update colormatch.py

Modified to detect and control monitors that can be adjusted using ddc channel. These will change brightness and temperature, and not require the colormatch script.

* Update colormatch.py

Added check to avoid going higher in temp than a monitor can support

* Update colormatch.py

Syntax changes

* Update colormatch.py

Syntax

* Update colormatch.py

More Syntax - I need to try an  IDE!

* Update colormatch.py

temp and lux were not set when levels were zero

* Update colormatch.py

* Update colormatch.py

* Update colormatch.py

* Update colormatch.py

Make scale adjustments

* reduce Warnings and clean up update script

move logic to suppress script adjustment to slidephow.py to avoid logging a warning for each picture.  Also clean up legacy updates in update.sh.  All those will have been applied in this branch.

* Update README.md

Formatting and URL port 7777

* Update README.md

spelling

* Update README.md

* update to install instructions

Added instructions to add http-auth.json for a manual install.

* Re-written temp logic

New color logic and move server to port 80.

* syntax bug

* Monitor brightness fix

Allow configuring a scaler to set Monitor brightness from lux

* Run from port 80

partial commit to run from port 80

* Update README.md

Added Install step for update crontab file

* Update svc_usb.py

Ignore hidden files (those that start with "."

* Update README.md

Made changes to re-purpose the Philip branch readme to this one.

* Update Readme

Removed Migration.md and minor updates to Readme

* Update Readme

Install and update from python3 branch
---
 MIGRATION.md              |   9 --
 README.md                 | 181 +++++++++++++++++++++++++-------
 frame.py                  |   2 +-
 modules/colormatch.py     | 210 ++++++++++++++++++++++++++++----------
 modules/display.py        |   4 +-
 modules/helper.py         |  19 +++-
 modules/oauth.py          |   2 +-
 modules/server.py         |   5 +-
 modules/servicemanager.py |   2 +-
 modules/slideshow.py      |   9 +-
 routes/oauthlink.py       |   4 +-
 services/base.py          |   4 +-
 services/svc_usb.py       |   4 +-
 update.sh                 |  16 ---
 14 files changed, 337 insertions(+), 134 deletions(-)
 delete mode 100644 MIGRATION.md

diff --git a/MIGRATION.md b/MIGRATION.md
deleted file mode 100644
index 75f87c8..0000000
--- a/MIGRATION.md
+++ /dev/null
@@ -1,9 +0,0 @@
-Manual steps to try this branch
-
-    sudo bash
-    cd
-    apt install apt-utils git fbset python3-pip python3-requests python3-requests-oauthlib python3-flask  imagemagick python3-smbus bc
-    
-    pip3 install requests requests-oauthlib flask flask-httpauth smbus
-    pip3 install netifaces
-    
diff --git a/README.md b/README.md
index 820a8fe..fd6989f 100755
--- a/README.md
+++ b/README.md
@@ -1,10 +1,25 @@
-# photoframe
+# Photoframe V2 Branch
+This is a development branch of photoframe targeting the next version of the software.  
+
+Significant changes for photoframe v2 include:
+
+- Designed to be used with a manual installation on top of a Raspberry Pi OS Lite `Buster` release
+- Use of Python 3.  
+- Support for HEIC photos.  This drove the need for the latest OS release.
+- ddcutil driven brightness and temperature changes.  This feature works with monitors
+  that can be adjusted using ddc over HDMI, including brightness and temperature. 
+  Existing branches do not adjust the screen brightness.
+- Support for TCS3472* color and lumen module e.g. https://www.ebay.com/itm/133600154256 
+- Improved color and temperature calculations both for accuracy and sensitivity
+- Managed at port 80 - no need to add :7777 to the URL.
+
+# Photoframe
 
 A Raspberry Pi (Zero, 1 or 3) software which automatically pulls photos from Google Photos and displays them
 on the attached screen, just like a photoframe. No need to upload photos to 3rd party service
 or fiddle with local storage or SD card.
 
-## why use this as opposed to buying one?
+## Why use this as opposed to buying one?
 
 Unlike most other frames out there, this one will automatically refresh and grab content
 from your photo collection, making it super simple to have a nice photo frame. Also uses
@@ -14,7 +29,7 @@ your expense report.
 It also has more unique features like ambient color temperature adjustments which allows
 the images to meld better with the room where it's running.
 
-# features
+# Features
 
 - Simple web interface for configuration
 - Google Photo search integration for more interesting images
@@ -26,50 +41,149 @@ the images to meld better with the room where it's running.
 - Power control via GPIO (turn RPi on/off)
 - Non-HDMI displays (SPI, DPI, etc)
 
-# requirements
+# Requirements
 
-- Raspberry Pi 1, 3 or Zero
+- Any Raspberry Pi
 - Display of some sort (HDMI or SPI/DPI displays)
-- Google Photos account
-- Internet
+- Another device with a web browser to manage the photoframe
+- Internet photos from Google or from URLs require Internet access for the Raspberry Pi
+- Familiarity wih Raspberry Pi and Linux command line procedures
 
-# installation
+# Installation
 
-On the release page, you'll find prepared raspbian image(s) for RaspberryPi 1, 3 or Zero
+This branch is not compatible with existing SD card images available at mrworf/photoframe.
+Once a new SD card image is create for V2, then much of this goes into MANUAL.md
 
-To use these (and I really recommend that to doing the manual steps), here's how:
+Start by installing Raspberry Pi OS Lite from a Buster release.  Jan 2021 or later.
 
-1. Download the image from the release page
-2. Use your favorite tool to load image onto a SD card, I recommend https://etcher.io/ which works on Windows, OSX and Linux
-3. Open the new drive called `boot` and edit the file called `wifi-config.txt`
-   Change the two fields to point out your wifi and the password needed for it
-4. Save the file
-5. Place SDcard in your RPi3 which is connected to a monitor/TV
-6. Start the RPi
-7. Wait (takes up to a minute depending on card and the fact that it's expanding to use the entire SDcard ... slower still on non-RPi3)
-8. Follow instructions shown on the display
+Make a shell available either by attaching a keyboard, or by enabling ssh 
 
-The default username/password for the web page is `photoframe` and `password`. This can be changed by editing the file called `http-auth.json` on the `boot` drive
+   Note: to enable ssh add two files to the SSD /boot drive:
+   
+   ssh
+```   
+     (no contents)
+```
+   wpa_supplicant.conf 
+```
+# Use this file instead of wifi-config.txt
+# Should set country properly
+
+country=us
+update_config=1
+ctrl_interface=/var/run/wpa_supplicant
+
+network={
+scan_ssid=1
+ssid="YourSSID"
+psk="YourWiFiPassword"
+}
+```
+
+If a keyboard is attached, you can use raspi-config to set up WiFi.
+
+use `sudo raspi-config` to set locale, time zone, WiFi and Country (if not done with the files above), and to enable I2C kernel module
+
+Bring the distro up to date:
+
+`sudo apt update && apt upgrade`
+
+From this point forward, it's recommended to `sudo bash` and then `cd` so that the commands are performed as root the /root directory
+
+Install additional dependencies:
+
+`apt install git python3-pip python3-requests python3-requests-oauthlib python3-flask`
 
-## tl;dr
+`apt install imagemagick python3-smbus bc ddcutil`
 
-Flash image to SDcard, edit `wifi-config.txt` and boot the RPi3 with the SDcard and follow instructions. Username and password is above this paragraph.
+`pip3 install requests requests-oauthlib flask flask-httpauth smbus`
 
-Once inside the web interface, select `GooglePhotos` from dropdown list in bottom-left corner and press `Add photo service`.
+`pip3 install netifaces` 
 
-# color temperature?
+Next, let's tweak the boot so we don't get a bunch of output
 
-Yes, photoframe can actually adjust the temperature of the image to suit the light in the room. For this to work, you need to install a TCS34725,
-see https://www.adafruit.com/product/1334 . This should be hooked up to the I2C bus, using this:
+Edit the `/boot/cmdline.txt` and add replace the term `console=tty1` with all of the following:
+
+```
+console=tty3 loglevel=3 consoleblank=0 vt.global_cursor_default=0 logo.nologo
+```
+
+You also need to edit the `/boot/config.txt`  in two places. 
+
+Add the following before the first `# uncomment` section
+
+```
+disable_splash=1
+framebuffer_ignore_alpha=1
+```
+
+And add the following to the `dtparam` section
+
+```
+dtparam=i2c2_iknowwhatimdoing
+```
+
+We also want to disable the first console (since that's going to be our frame). This is done by
+issuing
+
+```
+systemctl disable getty@tty1.service
+```
+
+And also do
+
+```
+systemctl mask plymouth-start.service
+```
+
+or you might still see the boot messages.
+
+Time to install photoframe, which means downloading the repo, install the service and reboot
+
+```
+cd /root
+git clone --branch python3 --single-branch https://github.com/dadr/photoframe.git
+cd photoframe
+cp frame.service /etc/systemd/system/
+systemctl enable /etc/systemd/system/frame.service
+reboot now
+```
+
+To get automatic updates, create a file `/etc/cron.d/photoframe`  with the following contents:
+```
+# Check once a week for updates to the photoframe software.
+15 3    * * *   root    /root/photoframe/update.sh
+```
+
+Finally, if you want the web interface to be login-password protected, then create the file `/boot/http-auth.json`  with the following edited to suite:
+
+```
+{"user":"photoframe","password":"password"}
+
+```
+
+# Usage
+
+photoframe is managed using a browser on the same WiFi subnet.  The URL is shown when no configuration is present, 
+and shown for a few seconds on bootup for a photoframe that has a working configuration.
+
+The default username/password for the web page is `photoframe` and `password`. This can be changed by editing the file called `http-auth.json` on the `boot` drive
+
+
+# color temperature
+
+This branch of photoframe is intended to work with color temperature modules.   Yes, photoframe can actually adjust the temperature of the image to suit the light in the room. For this to work, you need to install a TCS3472*,
+see https://www.adafruit.com/product/1334  and https://www.ebay.com/itm/133600154256. These should be hooked up to the I2C bus like this:
 
 ```
 3.3V -> Pin 1 (3.3V)
 SDA -> Pin 3 (GPIO 0)
 SCL -> Pin 5 (GPIO 1)
 GND -> Pin 9 (GND)
+LED -> Pin 9 (GND)
 ```
 
-You also need to tell your RPi3 to enable the I2C bus, start the `raspi-config` and go to submenu 5 (interfaces) and select I2C and enable it.
+Instructions above include enabling the I2C bus by using `raspi-config` and going to submenu 5 (interfaces) and select I2C and enable it.
 
 Once all this is done, you have one more thing left to do before rebooting, you need to download the imagemagick script that will adjust the image,
 please visit http://www.fmwconcepts.com/imagemagick/colortemp/index.php and download and store it as `colortemp.sh` inside `/root/photoframe_config`.
@@ -82,15 +196,12 @@ If photoframe is unable to use the sensor, it "usually" gives you helpful hints.
 
 *Note*
 
-The sensor is automatically detected as long as it is a TCS34725 device and it's connected correctly to the I2C bus of the raspberry pi. Once detected you'll get a new read-out in the web interface which details both white balance (kelvin) and light (lux).
+The sensor is automatically detected as long as it is a TCS3472* device and it's connected correctly to the I2C bus of the raspberry pi. Once detected you'll get a new read-out in the web interface which details both white balance (kelvin) and light (lux).
 
 If you don't get this read-out, look at your logfile. There will be hints like sensor not found or sensor not being the expected one, etc.
 
-## Annoyed with the LED showing on the TCS34725 board from Adafruit?
-
-Just ground the LED pin on the Adafruit board (for example by connecting it to Pin 9 on your RPi3)
 
-## Ambient powersave?
+## Ambient powersave
 
 Yes, using the same sensor, you can set a threshold and duration, if the ambient light is below said threshold for the duration, it will trigger
 powersave on the display. If the ambient brightness is above the threshold for same duration, it will wake up the display.
@@ -103,7 +214,7 @@ regardless of what the sensor says. The sensor is only used to extend the period
 Photoframe listens to GPIO 26 (default, can be changed) to power off (and also power on). If you connect a switch between pin 37 (GPIO 26) and pin 39 (GND), you'll be able
 to do a graceful shutdown as well as power on.
 
-# How come you contact photoframe.sensenet.nu ???
+# How come the Google service contacts photoframe.sensenet.nu ???
 
 Since Google doesn't approve of OAuth with dynamic redirect addresses,
 this project makes use of a lightweight service which allows registration
@@ -139,7 +250,7 @@ It's somewhat simplified, but shows the extra step taken to register your LAN ad
 If you want to see how it works and/or run your own, you'll find the code for this service under `extras` and requires
 php with memcached. Ideally you use a SSL endpoint as well.
 
-# faq
+# FAQs
 
 ## Can I avoid photoframe.sensenet.nu ?
 
@@ -161,7 +272,7 @@ By default, it logs very little and what it logs can be found under `/var/log/sy
 
 If you're having issues and you want more details, do the following as root:
 ```
-service frame stop
+systemctl stop frame
 /root/photoframe/frame.py --debug
 ```
 This will cause photoframe to run in the foreground and provide tons of debug information
diff --git a/frame.py b/frame.py
index 9b9a6c9..3a2aba0 100755
--- a/frame.py
+++ b/frame.py
@@ -45,7 +45,7 @@
 parser = argparse.ArgumentParser(description="PhotoFrame - A RaspberryPi based digital photoframe",
                                  formatter_class=argparse.ArgumentDefaultsHelpFormatter)
 parser.add_argument('--logfile', default=None, help="Log to file instead of stdout")
-parser.add_argument('--port', default=7777, type=int, help="Port to listen on")
+parser.add_argument('--port', default=80, type=int, help="Port to listen on")
 parser.add_argument('--countdown', default=10, type=int, help="Set seconds to countdown before starting slideshow")
 parser.add_argument('--listen', default="0.0.0.0", help="Address to listen on")
 parser.add_argument('--debug', action='store_true', default=False, help='Enable loads more logging')
diff --git a/modules/colormatch.py b/modules/colormatch.py
index 438b5be..de74cdc 100755
--- a/modules/colormatch.py
+++ b/modules/colormatch.py
@@ -17,8 +17,10 @@
 import smbus
 import time
 import os
+import re
 import subprocess
 import logging
+from . import debug
 
 
 class colormatch(Thread):
@@ -27,8 +29,18 @@ def __init__(self, script, min=None, max=None):
         self.daemon = True
         self.sensor = False
         self.temperature = None
+        self.default_temp = 3350.0
         self.lux = None
+        self.sensor_scale = 6.0  # ToDo - set this from Configuration - This is (GA) from AMS App note DN40
+        self.lux_scale = 1.0  # ToDo - set this from Configuration - Monitor Brightness = lux * lux_scale
         self.script = script
+        self.mon_adjust = False
+        self.mon_min_bright = 0.0  # Can't get this through ddc - assume 0
+        self.mon_max_bright = 0.0
+        self.mon_min_temp = 0.0
+        self.mon_max_temp = 0.0
+        self.mon_temp_inc = 0.0
+        self.mon_max_inc = 126.0  # Can't get this through ddc - has to be set by hand for now
         self.void = open(os.devnull, 'wb')
         self.min = min
         self.max = max
@@ -38,6 +50,40 @@ def __init__(self, script, min=None, max=None):
             self.hasScript = os.path.exists(self.script)
         else:
             self.hasScript = False
+        if os.path.exists("/usr/bin/ddcutil") and os.path.exists("/dev/i2c-2"):
+            # Logic to read monitor adjustment ranges and increments from ddc channel
+            # This is written assuming the monitor is a HP Z24i - If the regex strings differ for other
+            #    monitors, then this section may need to be replicated and "ddcutil detect" used to
+            #    determine the monitor type.  At that point, it may be better to have a new module -
+            #    similar to self.script - or a data file/structure can be created with the regex expressions for
+            #    various monitors
+            self.mon_adjust = True
+            try:
+                temp_str = debug.subprocess_check_output(['/usr/bin/ddcutil', 'getvcp', '0B'])
+                self.mon_temp_inc = int(re.search('([0-9]*) (degree)', temp_str).group(1))
+                logging.debug('Monitor temp increment is %i' % self.mon_temp_inc)
+            except:
+                logging.exception('ddcutil is present but not getting temp increment from monitor. ')
+                self.mon_adjust = False
+            try:
+                temp_str = debug.subprocess_check_output(['/usr/bin/ddcutil', 'getvcp', '0C'])
+                self.mon_min_temp = int(re.search('([0-9]*) (\\+)', temp_str).group(1))
+                logging.debug('Monitor min temp is %i' % self.mon_min_temp)
+            except:
+                logging.exception('ddcutil is present but not getting min temp status from monitor. ')
+                self.mon_adjust = False
+            try:
+                temp_str = debug.subprocess_check_output(['/usr/bin/ddcutil', 'getvcp', '10'])
+                self.mon_max_bright = int(re.search('(max value \\= *) ([0-9]*)', temp_str).group(2))
+                logging.debug('Monitor max brightness is %i' % self.mon_max_bright)
+            except:
+                logging.exception('ddcutil is present but not getting brightness info from monitor')
+                self.mon_adjust = False
+            if self.mon_adjust == True:
+                logging.info('Monitor adjustments enabled')
+        else:
+            logging.debug('/usr/bin/ddcutil or /dev/i2c-2 not found - cannot adjust monitor')
+            self.mon_adjust = False
 
         self.start()
 
@@ -66,7 +112,7 @@ def setUpdateListener(self, listener):
     def adjust(self, filename, filenameTemp, temperature=None):
         if not self.allowAdjust or not self.hasScript:
             return False
-
+            
         if self.temperature is None or self.sensor is None:
             logging.debug('Temperature is %s and sensor is %s', repr(self.temperature), repr(self.sensor))
             return False
@@ -92,48 +138,82 @@ def adjust(self, filename, filenameTemp, temperature=None):
             logging.exception('Unable to run %s:', self.script)
             return False
 
-    # The following function (_temperature_and_lux) is lifted from the
-    # https://github.com/adafruit/Adafruit_CircuitPython_TCS34725 project and
-    # is under MIT license, this license ONLY applies to said function and no
-    # other part of this project.
-    #
-    # The MIT License (MIT)
-    #
-    # Copyright (c) 2017 Tony DiCola for Adafruit Industries
+    def adjustableMonitor(self):
+        return self.mon_adjust
+    
+    def setMonBright(self):
+        brightness = self.lux * self.lux_scale
+        if brightness > self.mon_max_bright:
+            brightness = self.mon_max_bright
+        if brightness < self.mon_min_bright:
+            brightness = self.mon_min_bright
+        try:
+            debug.subprocess_call(['/usr/bin/ddcutil', 'setvcp', '10', repr(int(brightness))])
+            logging.debug('setMonBright set monitor to %s percent' % repr(int(brightness)))
+        except:
+            logging.debug('setMonBright failed to set monitor to %s' % repr(int(brightness)))
+            return False
+        return True
+            
+    def setMonTemp(self):
+        temp = self.temperature
+        if self.max:
+            if temp > self.max:
+                temp = self.max
+        if self.min:
+            if temp < self.min:
+                temp = self.min
+        if temp > self.mon_max_temp:
+            temp = self.mon_max_temp
+        if temp < self.mon_min_temp:
+            temp = self.mon_min_temp
+        tempset = int((temp - self.mon_min_temp)/self.mon_temp_inc)
+        if tempset > self.mon_max_inc:
+            tempset = self.mon_max_inc
+        try:
+            debug.subprocess_call(['/usr/bin/ddcutil', 'setvcp', '0C', repr(tempset)])
+            logging.debug('setMonTempt set monitor to %s temp' % repr(temp))
+        except:
+            logging.debug('setMonTemp failed to set monitor to %s' % repr(temp))
+            return False
+        return True
+
+    ###################################################################################
+    # It seems that there is a widespread sharing of incorrect code to read the TCS3472.
     #
-    # Permission is hereby granted, free of charge, to any person obtaining a copy
-    # of this software and associated documentation files (the "Software"), to deal
-    # in the Software without restriction, including without limitation the rights
-    # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-    # copies of the Software, and to permit persons to whom the Software is
-    # furnished to do so, subject to the following conditions:
+    # The following is based on the equations and coefficients provided by the manufacturer
+    # (AMS) in their Application Note DN40-Rev 1.0 Appendix I
+    # While it's hard to test this without expensive equipment, I was able to get a temp reading
+    # very close to 2700K using LED lighting from a bulb with the same spec.
     #
-    # The above copyright notice and this permission notice shall be included in
-    # all copies or substantial portions of the Software.
+    # Note, if a sensor other than a TCS3472 is used, these will no longer be correct.
+    # However this is correct for TCS34721, TCS34723, TCS34725 and TCS34727
     #
-    # 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.
-    def _temperature_and_lux(self, data):
-        """Convert the 4-tuple of raw RGBC data to color temperature and lux values. Will return
-           2-tuple of color temperature and lux."""
-        r, g, b, _ = data
-        x = -0.14282 * r + 1.54924 * g + -0.95641 * b
-        y = -0.32466 * r + 1.57837 * g + -0.73191 * b
-        z = -0.68202 * r + 0.77073 * g + 0.56332 * b
-        divisor = x + y + z
-        n = (x / divisor - 0.3320) / (0.1858 - y / divisor)
-        cct = 449.0 * n**3 + 3525.0 * n**2 + 6823.3 * n + 5520.33
-        return cct, y
-    ###################################################################################
-
-    # This function is mostly based of the example provided by Brad Berkland's blog:
-    # http://bradsrpi.blogspot.com/2013/05/tcs34725-rgb-color-sensor-raspberry-pi.html
+    # Finally, the vairable sensor_scale is used to compensate for light attenuation for sensors
+    # behind glass, or in some cases behind acrylic rods.
     #
+    def _temperature_and_lux(self, r, g, b, c, tms_gain, tms_atime):
+        itime = (256 - tms_atime) * 2.4
+        gain_table = {0: 1, 1: 4, 2: 16, 3: 60}
+        gain = gain_table[tms_gain]
+        if (self.sensor_scale <= 0):
+            cpl = 1.0  # protect from bad settings
+        else:
+            cpl = (gain * itime) / (self.sensor_scale * 310)
+        ir = float(r + g + b - c)/2
+        rp = float(r) - ir
+        gp = float(g) - ir
+        bp = float(b) - ir
+        lux = ((.136 * rp) + gp - (.444 * bp))/cpl
+        if (lux < 0):
+            lux = 0
+        if (rp == 0):
+            temp = self.default_temp
+        else:
+            temp = (3810 * ( bp / rp )) + 1391
+        return temp, lux
+    
+    
     def run(self):
         try:
             bus = smbus.SMBus(1)
@@ -149,21 +229,40 @@ def run(self):
             logging.info('ColorSensor not available')
             return
         ver = bus.read_byte(0x29)
-        # version # should be 0x44
-        if ver == 0x44:
+        # version # should be 0x44 or 0x4D
+        if (ver == 0x44) or (ver == 0x4D):
             # Make sure we have the needed script
-            if not os.path.exists(self.script):
+            if not (os.path.exists(self.script) or self.mon_adjust):
                 logging.info(
-                    'No color temperature script, download it from '
+                    'No color temperature script or adjustable monitor detected, download the script from '
                     'http://www.fmwconcepts.com/imagemagick/colortemp/index.php and save as "%s"' % self.script
                 )
                 self.allowAdjust = False
-            self.allowAdjust = True
-
+            else:
+                self.allowAdjust = True
+            self.sensor = True
+            
+            # Configure Sensor
+            # Todo - The app note suggests checking the clear value, and if it's less than 100,
+            # to change the gain to increase sensitivity.  That's not yet implemented here.
+            # Lok here for algorithm:
+            # https://ams.com/documents/20143/36005/AmbientLightSensors_AN000171_2-00.pdf/9d1f1cd6-4b2d-1de7-368f-8b372f3d8517
+            #
+            tms_gain = 0x1    # Must be 0, 1, 2, 3 which become 1x, 4x, 16x, and 60x gain
+            tms_atime = 0x0   # Must be 0xFF, 0xF6, 0xDB, 0xC0, 0x00 which become 2.4, 24, 101, 154, 700ms
+            tms_wtime = 0xFF  # Must be 0xFF, 0xAB, 0x00 which become 2.4, 204 614ms
+            
             bus.write_byte(0x29, 0x80 | 0x00)  # 0x00 = ENABLE register
             bus.write_byte(0x29, 0x01 | 0x02)  # 0x01 = Power on, 0x02 RGB sensors enabled
+            bus.write_byte(0x29, 0x80 | 0x01)  # 0x01 = ATIME (Integration time) register
+            bus.write_byte(0x29, tms_atime)
+            bus.write_byte(0x29, 0x80 | 0x03)  # 0x03 = WTIME (Wait) register
+            bus.write_byte(0x29, tms_wtime)
+            bus.write_byte(0x29, 0x80 | 0x0F)  # 0x0F = Control register
+            bus.write_byte(0x29, tms_gain)
+            
+            
             bus.write_byte(0x29, 0x80 | 0x14)  # Reading results start register 14, LSB then MSB
-            self.sensor = True
             logging.debug('TCS34725 detected, starting polling loop')
             while True:
                 data = bus.read_i2c_block_data(0x29, 0)
@@ -171,19 +270,18 @@ def run(self):
                 red = data[3] << 8 | data[2]
                 green = data[5] << 8 | data[4]
                 blue = data[7] << 8 | data[6]
-                if red > 0 and green > 0 and blue > 0 and clear > 0:
-                    temp, lux = self._temperature_and_lux((red, green, blue, clear))
-                    self.temperature = temp
-                    self.lux = lux
-                else:
-                    # All zero Happens when no light is available, so set temp to zero
-                    self.temperature = 0
-                    self.lux = 0
-
+                
+                self.temperature, self.lux = self._temperature_and_lux(red, green, blue, clear, tms_gain, tms_atime)
+                                  
                 if self.listener:
                     self.listener(self.temperature, self.lux)
+                    
+                if self.mon_adjust:
+                    logging.debug('Adjusting monitor to %3.2f lux and %5.0fK temp' % (self.lux, self.temperature))
+                    self.setMonBright()
+                    self.setMonTemp()
 
-                time.sleep(1)
+                time.sleep(15)  # Not sure how often it's acceptable to change monitor settings
         else:
             logging.info('No TCS34725 color sensor detected, will not compensate for ambient color temperature')
             self.sensor = False
diff --git a/modules/display.py b/modules/display.py
index 2ae3ea5..8d516ce 100755
--- a/modules/display.py
+++ b/modules/display.py
@@ -184,7 +184,7 @@ def message(self, message, showConfig=True):
 
         url = 'caption:'
         if helper.getDeviceIp() is not None and showConfig:
-            url = 'caption:Configuration available at http://%s:7777' % helper.getDeviceIp()
+            url = 'caption:Configuration available at http://%s' % helper.getDeviceIp()
 
         args = [
             'convert',
@@ -229,8 +229,6 @@ def image(self, filename):
         args = [
             'convert',
             filename + '[0]',
-            '-resize',
-            '%dx%d' % (self.width, self.height),
             '-background',
             'black',
             '-gravity',
diff --git a/modules/helper.py b/modules/helper.py
index b796d9a..ab547da 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -41,8 +41,10 @@ class helper:
         'image/jpeg': 'jpg',
         'image/png': 'png',
         'image/gif': 'gif',
-        'image/bmp': 'bmp'
-        # HEIF to be added once I get ImageMagick running with support
+        'image/bmp': 'bmp',
+        'image/heic': 'heic',
+        'image/heif': 'heif'
+        # MIME types: heif-sequence, heic-sequence, avif, avif-sequence might also be added if needed
     }
 
     @staticmethod
@@ -205,7 +207,7 @@ def makeFullframe(filename, displayWidth, displayHeight, zoomOnly=False, autoCho
         border = None
         spacing = None
 
-        # Calculate actual size of image based on display
+ 		# Calculate actual size of image based on display
         oar = (float)(width) / (float)(height)
         dar = (float)(displayWidth) / (float)(displayHeight)
 
@@ -387,6 +389,17 @@ def waitForNetwork(funcNoNetwork, funcExit):
 
     @staticmethod
     def autoRotate(ifile):
+        
+        # HEIC files do not work properly with blur and border, but do convert to jpg just fine
+        mimetype = helper.getMimetype(ifile)
+        if mimetype == 'image/heif' or mimetype == 'image/heic':
+            try:
+                subprocess.call(['/usr/bin/convert', ifile, 'jpg:'+ifile], stderr=subprocess.STDOUT)
+            except subprocess.CalledProcessError as e:
+                logging.exception('Unable to change image to jpg')
+                logging.error('Error: Could not convert', mimetype, ' to jpg')
+                
+        # resume processing autorotate        
         if not os.path.exists('/usr/bin/jpegexiforient'):
             logging.warning(
                 'jpegexiforient is missing, no auto rotate available. '
diff --git a/modules/oauth.py b/modules/oauth.py
index 66862c1..c111316 100755
--- a/modules/oauth.py
+++ b/modules/oauth.py
@@ -119,7 +119,7 @@ def request(self, uri, destination=None, params=None, data=None, usePost=False):
 
     def getRedirectId(self):
         r = requests.get('%s/?register' % self.ridURI)
-        return r.content
+        return r.content.decode('utf-8')
 
     def initiate(self):
         self.rid = self.getRedirectId()
diff --git a/modules/server.py b/modules/server.py
index fd94691..ecd77fa 100755
--- a/modules/server.py
+++ b/modules/server.py
@@ -42,7 +42,7 @@ def wrap(*args, **kwargs):
 
 
 class WebServer(Thread):
-    def __init__(self, run_async=False, port=7777, listen='0.0.0.0', debug=False):
+    def __init__(self, run_async=False, port=80, listen='0.0.0.0', debug=False):
         Thread.__init__(self)
         self.port = port
         self.listen = listen
@@ -76,6 +76,9 @@ def check_password(username):
         self.app.after_request(self._nocache)
         self.app.before_request(self._logincheck)
 
+    def get_server_port(self):
+        return self.port
+    
     def _logincheck(self):
         if not request.endpoint:
             return
diff --git a/modules/servicemanager.py b/modules/servicemanager.py
index c9303b1..4eb4d9a 100755
--- a/modules/servicemanager.py
+++ b/modules/servicemanager.py
@@ -139,7 +139,7 @@ def _load(self):
                 self._SERVICES[svc.getId()] = {'service': svc, 'id': svc.getId(), 'name': svc.getName()}
 
     def _hash(self, text):
-        return hashlib.sha1(text).hexdigest()
+        return hashlib.sha1(text.encode('utf-8')).hexdigest()
 
     def _configChanged(self):
         self.configChanges += 1
diff --git a/modules/slideshow.py b/modules/slideshow.py
index 33b5654..7d95952 100755
--- a/modules/slideshow.py
+++ b/modules/slideshow.py
@@ -171,17 +171,17 @@ def waitForNetwork(self):
             lambda: self.display.message('No internet connection\n\nCheck router, wifi-config.txt or cable'),
             lambda: self.settings.getUser('offline-behavior') != 'wait'
         )
-        self.display.setConfigPage('http://%s:%d/' % (helper.getDeviceIp(), 7777))
+        self.display.setConfigPage('http://%s:%d/' % (helper.getDeviceIp(), server.get_server_port()))
 
     def handleErrors(self, result):
         if result is None:
             serviceStates = self.services.getAllServiceStates()
             if len(serviceStates) == 0:
                 msg = 'Photoframe isn\'t ready yet\n\nPlease direct your webbrowser to\n\n'
-                msg += 'http://%s:7777/\n\nand add one or more photo providers' % helper.getDeviceIp()
+                msg += 'http://%s/\n\nand add one or more photo providers' % helper.getDeviceIp()
             else:
                 msg = 'Please direct your webbrowser to\n\n'
-                msg += 'http://%s:7777/\n\nto complete the setup process' % helper.getDeviceIp()
+                msg += 'http://%s/\n\nto complete the setup process' % helper.getDeviceIp()
                 for svcName, state, additionalInfo in serviceStates:
                     msg += "\n\n"+svcName+": "
                     if state == 'OAUTH':
@@ -208,7 +208,8 @@ def handleErrors(self, result):
         return False
 
     def _colormatch(self, filenameProcessed):
-        if self.colormatch.hasSensor():
+        if self.colormatch.hasSensor() and not self.colormatch.adjustableMonitor():
+            # For Now: Only transform image if the monitor cannot be adjusted for temperature
             # For Now: Always process original image (no caching of colormatch-adjusted images)
             # 'colormatched_tmp.jpg' will be deleted after the image is displayed
             p, f = os.path.split(filenameProcessed)
diff --git a/routes/oauthlink.py b/routes/oauthlink.py
index 4b04d10..cb3e74d 100644
--- a/routes/oauthlink.py
+++ b/routes/oauthlink.py
@@ -16,6 +16,7 @@
 
 import logging
 import json
+import codecs
 
 from .baseroute import BaseRoute
 
@@ -51,7 +52,8 @@ def handle(self, app, **kwargs):
                 logging.error('No file part')
                 return self.setAbort(405)
             file = self.getRequest().files['filename']
-            data = json.load(file)
+            reader = codecs.getreader('utf-8')
+            data = json.load(reader(file))
             if 'web' in data:
                 data = data['web']
             if 'redirect_uris' in data and 'https://photoframe.sensenet.nu' not in data['redirect_uris']:
diff --git a/services/base.py b/services/base.py
index 61d6c3e..75bf164 100755
--- a/services/base.py
+++ b/services/base.py
@@ -182,6 +182,7 @@ def getId(self):
 
     def getImagesTotal(self):
         # return the total number of images provided by this service
+        sum = 0
         if self.needKeywords():
             for keyword in self.getKeywords():
                 if keyword not in self._STATE["_NUM_IMAGES"] or keyword not in self._STATE['_NEXT_SCAN'] \
@@ -190,7 +191,8 @@ def getImagesTotal(self):
                     logging.debug('Keywords either not scanned or we need to scan now')
                     self._getImagesFor(keyword)  # Will make sure to get images
                     self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY
-        return sum([self._STATE["_NUM_IMAGES"][k] for k in self._STATE["_NUM_IMAGES"]])
+                sum = sum + self._STATE["_NUM_IMAGES"][keyword]
+        return sum
 
     def getImagesSeen(self):
         count = 0
diff --git a/services/svc_usb.py b/services/svc_usb.py
index 47069f5..0404a0f 100755
--- a/services/svc_usb.py
+++ b/services/svc_usb.py
@@ -280,10 +280,10 @@ def unmountBaseDir(self):
 
     # All images directly inside '/photoframe' directory will be displayed without any keywords
     def getBaseDirImages(self):
-        return [x for x in os.listdir(self.baseDir) if os.path.isfile(os.path.join(self.baseDir, x))]
+        return [x for x in os.listdir(self.baseDir) if (not x.startswith(".") and os.path.isfile(os.path.join(self.baseDir, x)))]
 
     def getAllAlbumNames(self):
-        return [x for x in os.listdir(self.baseDir) if os.path.isdir(os.path.join(self.baseDir, x))]
+        return [x for x in os.listdir(self.baseDir) if (not x.startswith(".") and os.path.isdir(os.path.join(self.baseDir, x)))]
 
     def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize):
         if self.device is None:
diff --git a/update.sh b/update.sh
index 221cbf6..d1d25c5 100755
--- a/update.sh
+++ b/update.sh
@@ -63,23 +63,7 @@ if [ "$1" = "post" ]; then
 	####-vvv- ANYTHING HERE MUST HANDLE BEING RUN AGAIN AND AGAIN -vvv-####
 	#######################################################################
 
-	# Due to older version not enabling the necessary parts,
-	# we need to add i2c-dev to modules if not there
-	if ! grep "i2c-dev" /etc/modules-load.d/modules.conf >/dev/null ; then
-		echo "i2c-dev" >> /etc/modules-load.d/modules.conf
-		modprobe i2c-dev
-	fi
-
-	# Make sure all old files are moved into the new config folder
-	mkdir /root/photoframe_config >/dev/null 2>/dev/null
-	FILES="oauth.json settings.json http_auth.json colortemp.sh"
-	for FILE in ${FILES}; do
-		mv /root/${FILE} /root/photoframe_config/ >/dev/null 2>/dev/null
-	done
 
-	# We also have added more dependencies, so add more software
-	apt-get update
-	apt-get install -y libjpeg-turbo-progs python-netifaces
 
 	# Copy new service and reload systemd
 	cp frame.service /etc/systemd/system/

From 59bc22753f8aad08efc248b2f6755b60e9133166 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 5 Aug 2021 09:22:21 -0700
Subject: [PATCH 16/20] Enable support for other ports than 7777

---
 extras/html/index.php | 113 ++++++++++++++++++++++++------------------
 1 file changed, 64 insertions(+), 49 deletions(-)

diff --git a/extras/html/index.php b/extras/html/index.php
index d7026c4..c3717f5 100644
--- a/extras/html/index.php
+++ b/extras/html/index.php
@@ -16,63 +16,78 @@
 #
 
 $headers = getallheaders();
-if (!isset($headers["X-Real-IP"])) {
-        die("No IP provided");
+if (!isset($headers["X-Real-IP"]) && !isset($headers["x-forwarded-for"])) {
+	die("No IP provided");
 }
 $ip = $headers["X-Real-IP"];
+if ($ip == "")
+	$ip = $headers["x-forwarded-for"];
 
 // Connect to memcache
 $mem = new Memcache;
 $mem->addServer("127.0.0.1", 11211);
 
 if (isset($_GET["register"])) {
-        header("Content-Type: text/plain");
-        $id = MD5(uniqid(rand(), TRUE));
-        $mem->set($id, $id, 0, 600); // It lives for 10min
-        print($id);
+	header("Content-Type: text/plain");
+	$id = MD5(uniqid(rand(), TRUE));
+	$mem->set($id, $id, 0, 600); // It lives for 10min
+	print($id);
 } else if (isset($_GET["state"]) && strlen($_GET["state"]) > 1) {
-        $params = explode("-", $_GET["state"]);
-        if (count($params) < 2) {
-                http_response_code(404);
-                die("No such redirect 1");
-        }
-        if ($mem->get($params[0]) !== $params[0]) {
-                http_response_code(404);
-                die("No such redirect");
-        }
-        // We only allow redirects to private IPs
-        $dest = explode(".", $params[1]);
-        if (count($dest) == 4) {
-                // Filter bad apples
-                for ($i = 0; $i != 4; ++$i) {
-                        $dest[$i] = intval($dest[$i]);
-                        if ($dest[$i] < 0 || $dest[$i] > 255) {
-                                http_response_code(404);
-                                die("Invalid IP");
-                        }
-                }
-                if ($dest[0] == 10)
-                        $valid = TRUE;
-                else if ($dest[0] == 172 && $dest[1] > 15 && dest[1] < 32)
-                        $valid = TRUE;
-                else if ($dest[0] == 192 && $dest[1] == 168)
-                        $valid = TRUE;
-                else if ($dest[0] == 169 && $dest[1] == 254)
-                        $valid = TRUE;
-                else
-                        $valid = FALSE;
-                if ($valid)
-                        header(sprintf("Location: http://%d.%d.%d.%d:7777/callback?state=%s&code=%s",
-                        $dest[0], $dest[1], $dest[2], $dest[3],
-                        $_GET["state"],
-                        $_GET["code"]
-                        ));
-                        die();
-        }
-        http_response_code(404);
-        die("No such redirect");
+	$params = explode("-", $_GET["state"]);
+	if (count($params) < 2) {
+		http_response_code(404);
+		die("No such redirect 1");
+	}
+	if ($mem->get($params[0]) !== $params[0]) {
+		http_response_code(404);
+		die("No such redirect");
+	}
+	// We only allow redirects to private IPs
+	$parts = explode(":", $params[1]);
+	if (count($parts) > 2) {
+		http_response_code(404);
+		die("Invalid IP");
+	} else if (count($parts) == 2) {
+		$port = intval($parts[1]);
+		if ($port != 80 && $port < 1024) {
+			// Only port 80 is allowed when using priviliged ports
+			http_response_code(404);
+			die("Invalid port");
+		}
+	} else {
+		$port = 7777; // Legacy support
+	}
+	$dest = explode(".", $parts[0]);
+	if (count($dest) == 4) {
+		// Filter bad apples
+		for ($i = 0; $i != 4; ++$i) {
+			$dest[$i] = intval($dest[$i]);
+			if ($dest[$i] < 0 || $dest[$i] > 255) {
+				http_response_code(404);
+				die("Invalid IP");
+			}
+		}
+		if ($dest[0] == 10)
+			$valid = TRUE;
+		else if ($dest[0] == 172 && $dest[1] > 15 && dest[1] < 32)
+			$valid = TRUE;
+		else if ($dest[0] == 192 && $dest[1] == 168)
+			$valid = TRUE;
+		else if ($dest[0] == 169 && $dest[1] == 254)
+			$valid = TRUE;
+		else
+			$valid = FALSE;
+		if ($valid)
+			header(sprintf("Location: http://%d.%d.%d.%d:%d/callback?state=%s&code=%s",
+			$dest[0], $dest[1], $dest[2], $dest[3], $port,
+			$_GET["state"],
+			$_GET["code"]
+			));
+			die();
+	}
+	http_response_code(404);
+	die("No such redirect");
 } else {
-        http_response_code(404);
-        die("Sorry");
+	header("Location: https://github.com/mrworf/photoframe#photoframe");
+	die();
 }
-

From 9d1cd8672881d79b43004413643db8b35ea385ce Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 5 Aug 2021 22:00:02 -0700
Subject: [PATCH 17/20] Start work on config support

Also implemented Pexels service but you cannot use it yet
unless you hack the service ;-)
---
 modules/images.py      |   2 +-
 services/base.py       |  10 ++-
 services/svc_pexels.py | 171 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 178 insertions(+), 5 deletions(-)
 create mode 100755 services/svc_pexels.py

diff --git a/modules/images.py b/modules/images.py
index 3bb791f..9fc1c09 100755
--- a/modules/images.py
+++ b/modules/images.py
@@ -54,7 +54,7 @@ def setContentSource(self, source):
         return self
 
     def setId(self, id):
-        self.id = id
+        self.id = str(id) # We assume it's always a string
         return self
 
     def setMimetype(self, mimetype):
diff --git a/services/base.py b/services/base.py
index 75bf164..f27a590 100755
--- a/services/base.py
+++ b/services/base.py
@@ -182,6 +182,7 @@ def getId(self):
 
     def getImagesTotal(self):
         # return the total number of images provided by this service
+        logging.debug('getImagesTotal: Enter')
         sum = 0
         if self.needKeywords():
             for keyword in self.getKeywords():
@@ -192,6 +193,7 @@ def getImagesTotal(self):
                     self._getImagesFor(keyword)  # Will make sure to get images
                     self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY
                 sum = sum + self._STATE["_NUM_IMAGES"][keyword]
+        logging.debug('getImagesTotal: Exit')
         return sum
 
     def getImagesSeen(self):
@@ -685,7 +687,7 @@ def selectNextImage(self, keywords, images, supportedMimeTypes, displaySize):
             return image
         return None
 
-    def requestUrl(self, url, destination=None, params=None, data=None, usePost=False):
+    def requestUrl(self, url, destination=None, params=None, data=None, usePost=False, extraHeaders=None):
         result = RequestResult()
 
         if self._OAUTH is not None:
@@ -704,13 +706,13 @@ def requestUrl(self, url, destination=None, params=None, data=None, usePost=Fals
             while tries < 5:
                 try:
                     if usePost:
-                        r = requests.post(url, params=params, json=data, timeout=180)
+                        r = requests.post(url, params=params, json=data, timeout=180, headers=extraHeaders)
                     else:
-                        r = requests.get(url, params=params, timeout=180)
+                        r = requests.get(url, params=params, timeout=180, headers=extraHeaders)
                     break
                 except Exception:
                     logging.exception('Issues downloading')
-                time.sleep(tries / 10)  # Back off 10, 20, ... depending on tries
+                time.sleep(tries * 10)  # Back off 10, 20, ... depending on tries
                 tries += 1
                 logging.warning('Retrying again, attempt #%d', tries)
 
diff --git a/services/svc_pexels.py b/services/svc_pexels.py
new file mode 100755
index 0000000..948184f
--- /dev/null
+++ b/services/svc_pexels.py
@@ -0,0 +1,171 @@
+# This file is part of photoframe (https://github.com/mrworf/photoframe).
+#
+# photoframe is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# photoframe is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with photoframe.  If not, see .
+#
+from .base import BaseService
+import os
+import json
+import logging
+import time
+
+from modules.network import RequestResult
+from modules.helper import helper
+
+
+class Pexels(BaseService):
+    SERVICE_NAME = 'Pexels'
+    SERVICE_ID = 5
+    MAX_ITEMS = 1000
+
+    # Under development, uses configuration which the UX doesn't handle yet
+    AUTHKEY = ''
+
+    def __init__(self, configDir, id, name):
+        BaseService.__init__(self, configDir, id, name, needConfig=True)
+
+    def getConfigurationFields(self):
+        return {'authkey' : {'type' : 'STR', 'name' : 'API key', 'description' : 'A pexels.com API key in order to access their API endpoints'}}
+
+    def helpKeywords(self):
+        return 'Type in a query for the kind of images you want. Can also use "curated" for a curated selection'
+
+    def getQueryForKeyword(self, keyword):
+        result = None
+        extras = self.getExtras()
+        if extras is None:
+            extras = {}
+
+        if keyword == 'curated':
+            logging.debug('Use latest 1000 images')
+            result = {
+                'per_page': 80  # 80 is API max
+            }
+        else:
+            result = {
+                'per_page': 80,  # 80 is API max
+                'query' : keyword
+            }
+        return result
+
+    def selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize):
+        result = BaseService.selectImageFromAlbum(self, destinationDir, supportedMimeTypes, displaySize, randomize)
+        if result is not None:
+            return result
+
+        return BaseService.createImageHolder(self).setError('No (new) images could be found.\n'
+                                                            'Check spelling or make sure you have added albums')
+
+    def freshnessImagesFor(self, keyword):
+        filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json')
+        if not os.path.exists(filename):
+            return 0  # Superfresh
+        # Hours should be returned
+        return (time.time() - os.stat(filename).st_mtime) / 3600
+
+    def clearImagesFor(self, keyword):
+        filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json')
+        if os.path.exists(filename):
+            logging.info('Cleared image information for %s' % keyword)
+            os.unlink(filename)
+
+    def getImagesFor(self, keyword, rawReturn=False):
+        filename = os.path.join(self.getStoragePath(), self.hashString(keyword) + '.json')
+        result = []
+        if not os.path.exists(filename):
+            # First time, translate keyword into query
+            params = self.getQueryForKeyword(keyword)
+            if params is None:
+                logging.error('Unable to create query the keyword "%s"', keyword)
+                return [BaseService.createImageHolder(self)
+                        .setError('Unable to get photos using keyword "%s"' % keyword)]
+
+            if keyword == 'curated':
+                url = 'https://api.pexels.com/v1/curated'
+            else:
+                url = 'https://api.pexels.com/v1/search'
+            maxItems = Pexels.MAX_ITEMS  # Should be configurable
+
+            while len(result) < maxItems:
+                data = self.requestUrl(url, params=params, extraHeaders={'Authorization' : Pexels.AUTHKEY})
+                if not data.isSuccess():
+                    logging.warning('Requesting photo failed with status code %d', data.httpcode)
+                    logging.warning('More details: ' + repr(data.content))
+                    break
+                else:
+                    data = json.loads(data.content.decode('utf-8'))
+                    if 'photos' not in data:
+                        break
+                    logging.debug('Got %d entries, adding it to existing %d entries',
+                                  len(data['photos']), len(result))
+                    result += data['photos']
+                    if 'next_page' not in data:
+                        break
+                    url = data['next_page']
+                    params = None # Since they're built-in
+                    logging.debug('Fetching another result-set for this keyword')
+
+            if len(result) > 0:
+                with open(filename, 'w') as f:
+                    json.dump(result, f)
+            else:
+                logging.error('No result returned for keyword "%s"!', keyword)
+                return []
+
+        # Now try loading
+        if os.path.exists(filename):
+            try:
+                print(filename)
+                with open(filename, 'r') as f:
+                    albumdata = json.load(f)
+            except Exception:
+                logging.exception('Failed to decode JSON file, maybe it was corrupted? Size is %d',
+                                  os.path.getsize(filename))
+                logging.error('Since file is corrupt, we try to save a copy for later analysis (%s.corrupt)', filename)
+                try:
+                    if os.path.exists(filename + '.corrupt'):
+                        os.unlink(filename + '.corrupt')
+                    os.rename(filename, filename + '.corrupt')
+                except Exception:
+                    logging.exception('Failed to save copy of corrupt file, deleting instead')
+                    os.unlink(filename)
+                albumdata = None
+        if rawReturn:
+            return albumdata
+        return self.parseAlbumInfo(albumdata, keyword)
+
+    def parseAlbumInfo(self, data, keyword):
+        # parse Pexels specific keys into a format that the base service can understand
+        if data is None:
+            return None
+        parsedImages = []
+        for entry in data:
+            try:
+                item = BaseService.createImageHolder(self)
+                item.setId(entry['id'])
+                item.setSource(entry['url'])
+                item.setUrl(entry['src']['original'])
+                item.setDimensions(entry['width'], entry['height'])
+                item.allowCache(True)
+                item.setContentProvider(self)
+                item.setContentSource(keyword)
+                parsedImages.append(item)
+            except Exception:
+                logging.exception('Entry could not be loaded')
+                logging.debug('Contents of entry: ' + repr(entry))
+        return parsedImages
+
+    def getContentUrl(self, image, hints):
+        # Utilize pexels to shrink it for us
+        logging.debug('PEXEL URL: %s', image.url)
+        return image.url + "?auto=compress&cs=tinysrgb&fit=crop&h=%d&w=%s" % (hints['size']["height"], hints['size']["width"])

From 7d07213d8a766ccdab9ca54432e60d063ff73dbe Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Thu, 5 Aug 2021 22:31:25 -0700
Subject: [PATCH 18/20] Enable use of ports other than 7777 for OAuth

---
 frame.py          |  3 +++
 modules/helper.py | 12 +++++++++---
 modules/oauth.py  |  5 +++--
 3 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/frame.py b/frame.py
index 3a2aba0..d439cb3 100755
--- a/frame.py
+++ b/frame.py
@@ -73,6 +73,9 @@ def __init__(self, cmdline):
         if not path().validate():
             sys.exit(255)
 
+        # Little bit of a cheat
+        helper.SERVER_PORT = cmdline.port
+
         self.debugmode = cmdline.debug
 
         self.eventMgr = Events()
diff --git a/modules/helper.py b/modules/helper.py
index ab547da..fdc6db1 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -35,6 +35,8 @@
 
 
 class helper:
+    SERVER_PORT = 80 # This gets updated if changed via commandline
+
     TOOL_ROTATE = '/usr/bin/jpegtran'
 
     MIMETYPES = {
@@ -75,6 +77,10 @@ def getResolution():
                 break
         return res
 
+    @staticmethod
+    def getServerPort():
+        return helper.SERVER_PORT
+
     @staticmethod
     def getDeviceIp():
         try:
@@ -389,7 +395,7 @@ def waitForNetwork(funcNoNetwork, funcExit):
 
     @staticmethod
     def autoRotate(ifile):
-        
+
         # HEIC files do not work properly with blur and border, but do convert to jpg just fine
         mimetype = helper.getMimetype(ifile)
         if mimetype == 'image/heif' or mimetype == 'image/heic':
@@ -398,8 +404,8 @@ def autoRotate(ifile):
             except subprocess.CalledProcessError as e:
                 logging.exception('Unable to change image to jpg')
                 logging.error('Error: Could not convert', mimetype, ' to jpg')
-                
-        # resume processing autorotate        
+
+        # resume processing autorotate
         if not os.path.exists('/usr/bin/jpegexiforient'):
             logging.warning(
                 'jpegexiforient is missing, no auto rotate available. '
diff --git a/modules/oauth.py b/modules/oauth.py
index c111316..d408fd0 100755
--- a/modules/oauth.py
+++ b/modules/oauth.py
@@ -29,6 +29,7 @@
 class OAuth:
     def __init__(self, setToken, getToken, scope, extras=''):
         self.ip = helper.getDeviceIp()
+        self.port = helper.getServerPort()
         self.scope = scope
         self.oauth = None
         self.cbGetToken = getToken
@@ -127,7 +128,7 @@ def initiate(self):
         auth = OAuth2Session(self.oauth['client_id'],
                              scope=self.scope,  # ['https://www.googleapis.com/auth/photos'],
                              redirect_uri=self.ridURI,
-                             state='%s-%s-%s' % (self.rid, self.ip, self.extras))
+                             state='%s-%s:%d-%s' % (self.rid, self.ip, self.port, self.extras))
         authorization_url, state = auth.authorization_url(self.oauth['auth_uri'],
                                                           access_type="offline",
                                                           prompt="consent")
@@ -140,7 +141,7 @@ def complete(self, url):
             auth = OAuth2Session(self.oauth['client_id'],
                                  scope=self.scope,  # ['https://www.googleapis.com/auth/photos'],
                                  redirect_uri=self.ridURI,
-                                 state='%s-%s-%s' % (self.rid, self.ip, self.extras))
+                                 state='%s-%s:%d-%s' % (self.rid, self.ip, self.port, self.extras))
 
             token = auth.fetch_token(self.oauth['token_uri'],
                                      client_secret=self.oauth['client_secret'],

From 84ca47cfb2c3a089f9d4ec13478a161817396946 Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Fri, 6 Aug 2021 09:55:35 -0700
Subject: [PATCH 19/20] Fix exception when network disappears

---
 services/base.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/services/base.py b/services/base.py
index f27a590..135164c 100755
--- a/services/base.py
+++ b/services/base.py
@@ -580,11 +580,11 @@ def fetchImage(self, image, destinationDir, supportedMimeTypes, displaySize):
 
             try:
                 result = self.requestUrl(url, destination=filename)
-            except (RequestResult.RequestExpiredToken, RequestInvalidToken):
+            except (RequestResult.RequestExpiredToken, RequestResult.RequestInvalidToken):
                 logging.exception('Cannot fetch due to token issues')
                 result = RequestResult().setResult(RequestResult.OAUTH_INVALID)
                 self._OAUTH = None
-            except requests.exceptions.RequestException:
+            except RequestNoNetwork:
                 logging.exception('request to download image failed')
                 result = RequestResult().setResult(RequestResult.NO_NETWORK)
 

From 1a9dee82257b396b4b1c5cc1477b128919890a4e Mon Sep 17 00:00:00 2001
From: Henric Andersson 
Date: Sat, 23 Dec 2023 12:41:46 -0800
Subject: [PATCH 20/20] Minor tweaks

---
 modules/slideshow.py   |  2 +-
 services/base.py       | 12 ++++++++++--
 services/svc_pexels.py |  2 +-
 3 files changed, 12 insertions(+), 4 deletions(-)

diff --git a/modules/slideshow.py b/modules/slideshow.py
index 7d95952..8f71fec 100755
--- a/modules/slideshow.py
+++ b/modules/slideshow.py
@@ -171,7 +171,7 @@ def waitForNetwork(self):
             lambda: self.display.message('No internet connection\n\nCheck router, wifi-config.txt or cable'),
             lambda: self.settings.getUser('offline-behavior') != 'wait'
         )
-        self.display.setConfigPage('http://%s:%d/' % (helper.getDeviceIp(), server.get_server_port()))
+        self.display.setConfigPage('http://%s:%d/' % (helper.getDeviceIp(), helper.getServerPort()))
 
     def handleErrors(self, result):
         if result is None:
diff --git a/services/base.py b/services/base.py
index 135164c..9e37ba5 100755
--- a/services/base.py
+++ b/services/base.py
@@ -29,7 +29,7 @@
 from modules.network import RequestInvalidToken
 from modules.network import RequestExpiredToken
 from modules.images import ImageHolder
-
+from modules import debug
 from modules.memory import MemoryManager
 
 # This is the base implementation of a service. It provides all the
@@ -180,19 +180,27 @@ def setName(self, newName):
     def getId(self):
         return self._ID
 
+    CONCURRENCY=0
+
     def getImagesTotal(self):
         # return the total number of images provided by this service
         logging.debug('getImagesTotal: Enter')
+        BaseService.CONCURRENCY += 1
+        if BaseService.CONCURRENCY > 1:
+            logging.error('Multiple threads accessing getImagesTotal!')
+            print(repr(debug.stacktrace()))
+
         sum = 0
         if self.needKeywords():
             for keyword in self.getKeywords():
                 if keyword not in self._STATE["_NUM_IMAGES"] or keyword not in self._STATE['_NEXT_SCAN'] \
                   or self._STATE['_NEXT_SCAN'][keyword] < time.time():
 
-                    logging.debug('Keywords either not scanned or we need to scan now')
+                    logging.debug('Keywords either not scanned or we need to scan now') # ERROR! This will cause this method to do more than it should
                     self._getImagesFor(keyword)  # Will make sure to get images
                     self._STATE['_NEXT_SCAN'][keyword] = time.time() + self.REFRESH_DELAY
                 sum = sum + self._STATE["_NUM_IMAGES"][keyword]
+        BaseService.CONCURRENCY -= 1
         logging.debug('getImagesTotal: Exit')
         return sum
 
diff --git a/services/svc_pexels.py b/services/svc_pexels.py
index 948184f..2a5fd32 100755
--- a/services/svc_pexels.py
+++ b/services/svc_pexels.py
@@ -32,7 +32,7 @@ class Pexels(BaseService):
     AUTHKEY = ''
 
     def __init__(self, configDir, id, name):
-        BaseService.__init__(self, configDir, id, name, needConfig=True)
+        BaseService.__init__(self, configDir, id, name, needConfig=False)
 
     def getConfigurationFields(self):
         return {'authkey' : {'type' : 'STR', 'name' : 'API key', 'description' : 'A pexels.com API key in order to access their API endpoints'}}