diff --git a/README.md b/README.md
index 517da8c..9ffd1f0 100644
--- a/README.md
+++ b/README.md
@@ -223,7 +223,9 @@ GND -> 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.
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/`.
+please visit http://www.fmwconcepts.com/imagemagick/colortemp/index.php and download and store it as `colortemp.sh` inside `/root/photoframe_config`.
+
+Don't forget to make it executable by `chmod +x /root/photoframe_config/colortemp.sh` or it will still not work.
You're done! Reboot your RPi3 (So I2C gets enabled) and from now on, all images will get adjusted to match the ambient color temperature.
diff --git a/display-drivers/README.md b/display-drivers/README.md
new file mode 100644
index 0000000..0a2e9e3
--- /dev/null
+++ b/display-drivers/README.md
@@ -0,0 +1,93 @@
+# Display Drivers
+
+Photoframe now supports uploading and enabling of internal displays for the Raspberry Pi family,
+the only requirement is that it can be supported by the currently used kernel and modules.
+
+Since a lot of the smaller displays rely on the built-in fbtft driver, it means that in many
+cases, all you really need is a DeviceTree Overlay, essentially configuration files for the
+device driver so it knows how to talk to the new display.
+
+## What's included
+
+Today, only the waveshare 3.5" IPS (model B) is provided since that was my development system.
+But you can create and share these display "drivers" easily yourself.
+
+## How to write a display driver package
+
+Start with an empty folder, copy the necessary files for it to work, usually one or two files
+ending in `.dtb` or `.dtbo`.
+
+Next, create a file called `INSTALL` (yes, all caps, important) in the same folder. Open the
+file and create the following structure:
+
+```
+[install]
+
+[options]
+```
+
+### The install section
+This is a very simple `key/value` pair setup. First part (key) refers to the file included in the
+package. The path to the file is based on the location of the `INSTALL` file. You can use
+sub-directories if you need to, but if you do so, they must adhere to the same rule.
+
+The value part refers to where the file should be copied when activated. Typically this is
+somewhere in `/boot/`.
+
+For example, in the waveshare case, this section looks like this:
+```
+[install]
+waveshare35b-overlay.dtb=/boot/overlays/waveshare35b.dtbo
+waveshare35b-overlay.dtb=/boot/overlays/waveshare35b-overlay.dtb
+```
+As you can see, the key here is used multiple times, this is because they place this file in two
+locations with different names (but it's the same file).
+
+NOTE! The installer will NOT create any directories when activating.
+
+### The options section
+
+This is also a `key/value` setup, but unlike the `install` section, here the key is UNIQUE. If you
+define a key multiple times, only the last definition will be used.
+
+At the very least, this section holds the key `dtoverlay` which is the `/boot/config.txt` keyword
+for pointing out an overlay to use. But you can add as many things as you'd like (some DPI displays
+require a multitude of key/value pairs).
+
+In the waveshare 3.5" display case, all it does is point out the overlay:
+```
+[options]
+dtoverlay=waveshare35b
+```
+
+## Saving the display driver package
+
+Once you have written your `INSTALL` file and added the needed files to the folder you
+created earlier, all you need to do now is create a zip file out of the contents and give
+the file a nice name (like, `waveshare35b.zip`) since that's the name used to identify
+the driver.
+
+## I updated my driver, now what?
+
+Simply upload it again. The old driver will be deleted and replaced with the new one.
+
+## This all seem complicated, do you have an example?
+
+Sure, just unzip the `waveshare35b.zip` and look at it for guidance.
+
+## What is `manifest.json` ?
+
+That's a generated file by photoframe which it creates upon installing a driver. You can
+create sub-directories in `display-drivers` with pre-processed drivers which will then be
+available by default when installing photoframe.
+
+Note that if you install a driver with the same name as one of the provided ones, the new
+driver will take priority
+
+## Known gotchas
+
+If you install a driver which you're already using, you need to switch to HDMI and back to
+force update the active driver (and no, no need to reboot when going to HDMI, only when
+you go back to your updated driver).
+
+This will eventually be fixed.
\ No newline at end of file
diff --git a/display-drivers/waveshare35b.zip b/display-drivers/waveshare35b.zip
new file mode 100644
index 0000000..14d7c11
Binary files /dev/null and b/display-drivers/waveshare35b.zip differ
diff --git a/frame.py b/frame.py
index 8bbcb72..e416591 100755
--- a/frame.py
+++ b/frame.py
@@ -38,14 +38,26 @@
from modules.oauth import OAuth
from modules.slideshow import slideshow
from modules.colormatch import colormatch
+from modules.drivers import drivers
void = open(os.devnull, 'wb')
+# Supercritical, since we store all photoframe files in a subdirectory, make sure to create it
+if not os.path.exists('/root/photoframe_config'):
+ try:
+ os.mkdir('/root/photoframe_config')
+ except:
+ logging.exception('Unable to create configuration directory, cannot start')
+ sys.exit(255)
+elif not os.path.isdir('/root/photoframe_config'):
+ logging.error('/root/photoframe_config isn\'t a folder, cannot start')
+ sys.exit(255)
import requests
from requests_oauthlib import OAuth2Session
-from flask import Flask, request, redirect, session, url_for, abort
+from flask import Flask, request, redirect, session, url_for, abort, flash
from flask.json import jsonify
from flask_httpauth import HTTPBasicAuth
+from werkzeug.utils import secure_filename
# used if we don't find authentication json
class NoAuth:
@@ -74,8 +86,9 @@ def wrap(*args, **kwargs):
logging.getLogger('urllib3').setLevel(logging.ERROR)
app = Flask(__name__, static_url_path='')
+app.config['UPLOAD_FOLDER'] = '/tmp/'
user = None
-userfiles = ['/boot/http-auth.json', '/root/http-auth.json']
+userfiles = ['/boot/http-auth.json', '/root/photoframe_config/http-auth.json']
for userfile in userfiles:
if os.path.exists(userfile):
@@ -126,34 +139,31 @@ def cfg_keyvalue(key, value):
return
if request.method == 'PUT':
+ status = True
if key == "keywords":
# Keywords has its own API
abort(404)
return
settings.setUser(key, value)
settings.save()
- if key in ['width', 'height', 'depth', 'tvservice']:
- display.setConfiguration(settings.getUser('width'), settings.getUser('height'), settings.getUser('depth'), settings.getUser('tvservice'))
- display.enable(True, True)
+ if key in ['display-driver']:
+ drv = settings.getUser('display-driver')
+ if drv == 'none':
+ drv = None
+ if not drivers.activate(drv):
+ settings.setUser('display-driver', 'none')
+ status = False
if key in ['timezone']:
# Make sure we convert + to /
settings.setUser('timezone', value.replace('+', '/'))
helper.timezoneSet(settings.getUser('timezone'))
- if key in ['resolution']:
- # This one needs some massaging, we essentially deduce all settings from a string (DMT/CEA CODE HDMI)
- items = settings.getUser('resolution').split(' ')
- logging.debug('Items: %s', repr(items))
- resolutions = display.available()
- for res in resolutions:
- if res['code'] == int(items[1]) and res['mode'] == items[0]:
- logging.debug('Found this item: %s', repr(res))
- settings.setUser('width', res['width'])
- settings.setUser('height', res['height'])
- settings.setUser('depth', 32)
- settings.setUser('tvservice', value)
- display.setConfiguration(settings.getUser('width'), settings.getUser('height'), settings.getUser('depth'), settings.getUser('tvservice'))
- display.enable(True, True)
- break
+ if key in ['resolution', 'tvservice']:
+ width, height, tvservice = display.setConfiguration(value)
+ settings.setUser('tvservice', tvservice)
+ settings.setUser('width', width)
+ settings.setUser('height', height)
+ settings.save()
+ display.enable(True, True)
if key in ['display-on', 'display-off']:
timekeeper.setConfiguration(settings.getUser('display-on'), settings.getUser('display-off'))
if key in ['autooff-lux', 'autooff-time']:
@@ -163,7 +173,7 @@ def cfg_keyvalue(key, value):
if key in ['shutdown-pin']:
powermanagement.stopmonitor()
powermanagement = shutdown(settings.getUser('shutdown-pin'))
- return jsonify({'status':True})
+ return jsonify({'status':status})
elif request.method == 'GET':
if key is None:
@@ -212,7 +222,7 @@ def cfg_oauth_info():
abort(500)
data = request.json['web']
oauth.setOAuth(data)
- with open('/root/oauth.json', 'wb') as f:
+ with open('/root/photoframe_config/oauth.json', 'wb') as f:
json.dump(data, f);
return jsonify({'result' : True})
@@ -249,6 +259,9 @@ def cfg_details(about):
response = app.make_response(image)
response.headers.set('Content-Type', mime)
return response
+ elif about == 'drivers':
+ result = drivers.list().keys()
+ return jsonify(result)
elif about == 'timezone':
result = helper.timezoneList()
return jsonify(result)
@@ -263,6 +276,28 @@ def cfg_details(about):
abort(404)
+@app.route('/custom-driver', methods=['POST'])
+@auth.login_required
+def upload_driver():
+ if request.method == 'POST':
+ # check if the post request has the file part
+ if 'driver' not in request.files:
+ logging.error('No file part')
+ abort(405)
+ file = request.files['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')
+ abort(405)
+ filename = os.path.join('/tmp/', secure_filename(file.filename))
+ file.save(filename)
+ if drivers.install(filename):
+ return ''
+ else:
+ abort(500)
+ abort(405)
+
@app.route("/link")
@auth.login_required
def oauth_step1():
@@ -295,20 +330,27 @@ def web_template(file):
return app.send_static_file('template/' + file)
settings = settings()
+drivers = drivers()
+display = display()
+
if not settings.load():
# First run, grab display settings from current mode
current = display.current()
- logging.info('No display settings, using: %s' % repr(current))
- settings.setUser('tvservice', '%s %s HDMI' % (current['mode'], current['code']))
- settings.setUser('width', int(current['width']))
- settings.setUser('height', int(current['height']))
- settings.save()
-
+ if current is not None:
+ logging.info('No display settings, using: %s' % repr(current))
+ settings.setUser('tvservice', '%s %s HDMI' % (current['mode'], current['code']))
+ settings.save()
+ else:
+ logging.info('No display attached?')
if settings.getUser('timezone') == '':
settings.setUser('timezone', helper.timezoneCurrent())
settings.save()
-display = display(settings.getUser('width'), settings.getUser('height'), settings.getUser('depth'), settings.getUser('tvservice'))
+width, height, tvservice = display.setConfiguration(settings.getUser('tvservice'))
+settings.setUser('tvservice', tvservice)
+settings.setUser('width', width)
+settings.setUser('height', height)
+settings.save()
# Force display to desired user setting
display.enable(True, True)
@@ -333,8 +375,8 @@ def oauthSetToken(token):
oauth = OAuth(settings.get('local-ip'), oauthSetToken, oauthGetToken)
-if os.path.exists('/root/oauth.json'):
- with open('/root/oauth.json') as f:
+if os.path.exists('/root/photoframe_config/oauth.json'):
+ with open('/root/photoframe_config/oauth.json') as f:
data = json.load(f)
if 'web' in data: # if someone added it via command-line
data = data['web']
diff --git a/modules/colormatch.py b/modules/colormatch.py
index 527a007..f2407ed 100755
--- a/modules/colormatch.py
+++ b/modules/colormatch.py
@@ -133,7 +133,11 @@ def run(self):
# I2C address 0x29
# Register 0x12 has device ver.
# Register addresses must be OR'ed with 0x80
- bus.write_byte(0x29,0x80|0x12)
+ 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:
diff --git a/modules/display.py b/modules/display.py
index dd025f8..b666576 100755
--- a/modules/display.py
+++ b/modules/display.py
@@ -21,16 +21,44 @@
import json
class display:
- def __init__(self, width, height, depth, tvservice_params):
- self.setConfiguration(width, height, depth, tvservice_params)
+ def __init__(self):
self.void = open(os.devnull, 'wb')
+ self.params = None
- def setConfiguration(self, width, height, depth, tvservice_params):
- self.width = width
- self.height = height
+ def setConfiguration(self, tvservice_params):
self.enabled = True
- self.depth = depth
- self.params = tvservice_params
+
+ # Erase old picture
+ if self.params is not None:
+ self.clear()
+
+ result = display.validate(tvservice_params)
+ if result is None:
+ self.enabled = False
+ self.params = None
+ return (1280, 720, '')
+
+ self.width = result['width']
+ self.height = result['height']
+ 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':
+ return '/dev/fb1'
+ return '/dev/fb0'
+
+ def isHDMI(self):
+ return self.getDevice() == '/dev/fb0'
def get(self):
if self.enabled:
@@ -40,14 +68,14 @@ def get(self):
'8',
'-size',
'%dx%d' % (self.width, self.height),
- 'bgra:/dev/fb0[0]',
+ '%s:-' % (self.format),
'jpg:-'
]
else:
args = [
'convert',
'-size',
- '%dx%d' % (self.width, self.height),
+ '%dx%d' % (640, 360),
'-background',
'black',
'-fill',
@@ -57,17 +85,51 @@ def get(self):
'-weight',
'700',
'-pointsize',
- '64',
- 'label:%s' % "Powersave",
+ '32',
+ 'label:%s' % "Display off",
'-depth',
'8',
'jpg:-'
]
- result = subprocess.check_output(args, stderr=self.void)
+ if not self.enabled:
+ result = subprocess.check_output(args, stderr=self.void)
+ elif self.depth in [24, 32]:
+ with open(self.getDevice(), '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):
+ if self.depth in [24, 32]:
+ logging.debug('Sending image directly to framebuffer')
+ with open(self.getDevice(), 'wb') as f:
+ ret = subprocess.call(arguments, stdout=f, stderr=self.void)
+ elif self.depth == 16: # Typically RGB565
+ logging.debug('Sending image via RGB565 conversion to framebuffer')
+ # For some odd reason, cannot pipe the output directly to the framebuffer, use temp file
+ with open(self.getDevice(), '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)
+
+
def message(self, message):
+ if not self.enabled:
+ logging.debug('Don\'t bother, display is off')
+ return
+
args = [
'convert',
'-size',
@@ -85,12 +147,15 @@ def message(self, message):
'label:%s' % message,
'-depth',
'8',
- 'bgra:-'
+ '%s:-' % self.format
]
- with open('/dev/fb0', 'wb') as f:
- ret = subprocess.call(args, stdout=f, stderr=self.void)
+ self._to_display(args)
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',
@@ -105,63 +170,90 @@ def image(self, filename):
'%dx%d' % (self.width, self.height),
'-depth',
'8',
- 'bgra:-'
+ '%s:-' % self.format
]
- with open('/dev/fb0', 'wb') as f:
- ret = subprocess.call(args, stdout=f, stderr=self.void)
+ 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 force: # Make sure display is ON and set to our preference
- subprocess.call(['/opt/vc/bin/tvservice', '-e', self.params], stderr=self.void, stdout=self.void)
- time.sleep(1)
- subprocess.call(['/bin/fbset', '-depth', '8'], stderr=self.void)
- subprocess.call(['/bin/fbset', '-depth', str(self.depth), '-xres', str(self.width), '-yres', str(self.height), '-vxres', str(self.width), '-vyres', str(self.height)], stderr=self.void)
- else:
- subprocess.call(['/usr/bin/vcgencmd', 'display_power', '1'], stderr=self.void)
+ if self.isHDMI():
+ if force: # Make sure display is ON and set to our preference
+ subprocess.call(['/opt/vc/bin/tvservice', '-e', self.params], stderr=self.void, stdout=self.void)
+ time.sleep(1)
+ subprocess.call(['/bin/fbset', '-fb', self.getDevice(), '-depth', '8'], stderr=self.void)
+ 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:
+ subprocess.call(['/usr/bin/vcgencmd', 'display_power', '1'], stderr=self.void)
else:
- subprocess.call(['/usr/bin/vcgencmd', 'display_power', '0'], stderr=self.void)
+ self.clear()
+ if self.isHDMI():
+ subprocess.call(['/usr/bin/vcgencmd', 'display_power', '0'], stderr=self.void)
self.enabled = enable
def isEnabled(self):
return self.enabled
def clear(self):
- with open('/dev/fb0', 'wb') as f:
+ with open(self.getDevice(), 'wb') as f:
subprocess.call(['cat' , '/dev/zero'], stdout=f, stderr=self.void)
@staticmethod
- def current():
- '''
- output = subprocess.check_output(['/opt/vc/bin/tvservice', '-s'], stderr=subprocess.STDOUT)
- print('"%s"' % (output))
- # 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]*)', output)
- result = {
- 'group' : m.group(2),
- 'mode' : m.group(3),
- 'drive' : m.group(1),
- 'width' : m.group(4),
- 'height' : m.group(5)
- }
- return result
- '''
- output = 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)
- 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' : []
- }
+ def _internaldisplay():
+ if os.path.exists('/dev/fb1'):
+ entry = {
+ 'mode' : 'INTERNAL',
+ 'code' : '0',
+ 'width' : 0,
+ 'height' : 0,
+ 'rate' : 60,
+ 'aspect_ratio' : '',
+ 'scan' : '(internal)',
+ '3d_modes' : []
+ }
+ info = subprocess.check_output(['/bin/fbset', '-fb', '/dev/fb1'], 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['reverse'] = False
+ entry['code'] = int(parts[5])
+ if entry['code'] != 0:
+ return entry
+ return None
+
+ def current(self):
+ result = None
+ if self.isHDMI():
+ output = 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
@@ -171,10 +263,42 @@ def available():
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:
+ result.append(internal)
+
# Finally, sort by pixelcount
- return sorted(result, key=lambda k: k['width']*k['height'])
\ No newline at end of file
+ return sorted(result, key=lambda k: k['width']*k['height'])
+
+ @staticmethod
+ def validate(tvservice):
+ # 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')
+
+ return {
+ 'width':res['width'],
+ 'height':res['height'],
+ 'depth':res['depth'],
+ 'reverse':res['reverse'],
+ 'tvservice':'%s %s %s' % (res['mode'], res['code'], 'HDMI')
+ }
diff --git a/modules/drivers.py b/modules/drivers.py
new file mode 100644
index 0000000..9c86040
--- /dev/null
+++ b/modules/drivers.py
@@ -0,0 +1,246 @@
+# 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 .
+#
+import time
+import os
+import subprocess
+import logging
+import tempfile
+import shutil
+import json
+
+class drivers:
+ BUILTIN = '/root/photoframe/display-drivers'
+ EXTERNAL = '/root/photoframe_config/display-drivers/'
+ 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(drivers.EXTERNAL):
+ try:
+ os.mkdir(drivers.EXTERNAL)
+ except:
+ logging.exception('Unable to create "%s"', drivers.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(self):
+ result = {}
+ result = self._list_dir(drivers.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(drivers.EXTERNAL))
+
+ 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 _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 = {'driver' : os.path.basename(root), 'install' : [], 'options' : {}}
+ 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() == '[options]':
+ state = 2
+ 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:
+ 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)
+ config['options'][key] = value
+ except:
+ logging.exception('Failed to read INSTALL manifest')
+ return None
+ 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
+
+ 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
+
+ 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(drivers.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
+
+ # 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 True
+
+ 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 False
+
+ config = {'name':'', 'install':[], '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 False
+
+ # 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 False
+
+ # Next, load the config.txt and insert/replace our section
+ lines = []
+ try:
+ with open('/boot/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 False
+
+ # Add our options
+ if len(config['options']) > 0:
+ lines.append(drivers.MARKER)
+ for key in config['options']:
+ lines.append('%s=%s' % (key, config['options'][key]))
+
+ # 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 False
+
+ # On success, we rename and delete the old config
+ try:
+ os.rename('/boot/config.txt', '/boot/config.txt.old')
+ os.rename('/boot/config.txt.new', '/boot/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 True
diff --git a/modules/helper.py b/modules/helper.py
index 319537a..0ff95de 100755
--- a/modules/helper.py
+++ b/modules/helper.py
@@ -84,11 +84,19 @@ def makeFullframe(filename, imageWidth, imageHeight):
if height < imageHeight:
border = '0x%d' % width_border
spacing = '0x%d' % width_spacing
- logging.debug('Landscape image, reframing')
+ zoomed = ((imageHeight-height)/2-width_border)
+ logging.debug('Landscape image, reframing (visible zoomed is %d)' % zoomed)
+ if zoomed < 20:
+ logging.debug('That\'s less than 20px so skip reframing')
+ return False
elif height > imageHeight:
border = '%dx0' % width_border
spacing = '%dx0' % width_spacing
- logging.debug('Portrait image, reframing')
+ zoomed = ((imageWidth-width)/2-width_border)
+ logging.debug('Portrait image, reframing (visible zoomed is %d)' % zoomed)
+ if zoomed < 20:
+ logging.debug('That\'s less than 20px so skip reframing')
+ return False
else:
logging.debug('Image is fullscreen, no reframing needed')
return False
diff --git a/modules/settings.py b/modules/settings.py
index 3d87ee2..1901762 100755
--- a/modules/settings.py
+++ b/modules/settings.py
@@ -19,7 +19,8 @@
import random
class settings:
- CONFIGFILE = '/root/settings.json'
+ CONFIGFILE = '/root/photoframe_config/settings.json'
+ DEPRECATED_USER = ['resolution']
def __init__(self):
self.settings = {
@@ -28,7 +29,7 @@ def __init__(self):
'local-ip' : None,
'tempfolder' : '/tmp/',
'colortemp' : None,
- 'colortemp-script' : '/root/colortemp.sh',
+ 'colortemp-script' : '/root/photoframe_config/colortemp.sh',
'cfg' : None
}
self.userDefaults()
@@ -39,7 +40,6 @@ def userDefaults(self):
'height' : 1080,
'depth' : 32,
'tvservice' : 'DMT 82 DVI',
- 'resolution' : '', # Place holder, used to deduce correct resolution before setting TV service
'timezone' : '',
'interval' : 60, # Delay in seconds between images (minimum)
'display-off' : 22, # What hour (24h) to disable display and sleep
@@ -52,6 +52,7 @@ def userDefaults(self):
'autooff-time' : 0,
'powersave' : '',
'shutdown-pin' : 26,
+ 'display-driver' : 'none',
}
def load(self):
@@ -66,6 +67,10 @@ def load(self):
self.settings['cfg'] = tmp
self.settings['cfg'].update(tmp2)
+ # Remove deprecated fields
+ for field in settings.DEPRECATED_USER:
+ self.settings['cfg'].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']:
@@ -73,7 +78,7 @@ 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 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()
diff --git a/modules/slideshow.py b/modules/slideshow.py
index a0ffbbb..5fa2e99 100644
--- a/modules/slideshow.py
+++ b/modules/slideshow.py
@@ -102,6 +102,9 @@ def presentation(self):
keyword = self.settings.getKeyword(index)
imgs, cache = self.getImages(keyword)
+ if imgs is None:
+ # Try again!
+ continue
# If we've seen all images for this keyword, skip to next
if cache in seen:
diff --git a/modules/timekeeper.py b/modules/timekeeper.py
index 1fc6654..f2dd9f1 100644
--- a/modules/timekeeper.py
+++ b/modules/timekeeper.py
@@ -44,7 +44,7 @@ def setConfiguration(self, hourOn, hourOff):
logging.debug('hourOn = %s, hourOff = %s' % (repr(hourOn), repr(hourOff)))
def setPowermode(self, mode):
- if mode == '':
+ if mode == '' or mode == 'none':
self.ignoreSensor = True
self.ignoreSchedule = True
elif mode == 'sensor':
diff --git a/rgb565/README.md b/rgb565/README.md
new file mode 100644
index 0000000..30d3cea
--- /dev/null
+++ b/rgb565/README.md
@@ -0,0 +1,3 @@
+Simple tool to convert between RGB888 and RGB565.
+
+It's used to support 16bit displays like the ones from WaveShare
diff --git a/rgb565/rgb565 b/rgb565/rgb565
new file mode 100755
index 0000000..c64e639
Binary files /dev/null and b/rgb565/rgb565 differ
diff --git a/rgb565/rgb565.c b/rgb565/rgb565.c
new file mode 100644
index 0000000..802fe19
--- /dev/null
+++ b/rgb565/rgb565.c
@@ -0,0 +1,95 @@
+/**
+ *
+ * Simple tool to convert between RGB888 and RGB565
+ *
+ * Copyright 2018 Henric Andersson (henric@sensenet.nu)
+ *
+ * This file is part of 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 .
+ */
+
+#include
+#include
+#include
+#include
+
+#define RGB888toRGB565(r,g,b) ((((r) >> 3) << 11) | (((g) >> 2) << 5) | ((b) >> 3))
+#define RGB565toR8(x) ((((x) >> 11) & 0x1F) * 255 / 31)
+#define RGB565toG8(x) ((((x) >> 5) & 0x3F) * 255 / 63)
+#define RGB565toB8(x) ((((x) ) & 0x1F) * 255 / 31)
+
+#define RGB888_BUFSIZE (3*1024)
+#define RGB565_BUFSIZE (2*1024)
+
+void convert565to888(void) {
+ unsigned short* in_buffer = (unsigned short*)malloc(RGB565_BUFSIZE);
+ unsigned char* out_buffer = (unsigned char*)malloc(RGB888_BUFSIZE);
+ int i = 0;
+ int size = 0;
+ int remain = 0;
+ char buf[256];
+
+ while (1) {
+ size = read(0, in_buffer + remain, RGB565_BUFSIZE - remain);
+ if (size < 1 && remain == 0)
+ break;
+ size += remain;
+ remain = size - (size/2)*2;
+ for (i = 0; i < size/2; ++i) {
+ out_buffer[i*3+0] = RGB565toR8(in_buffer[i]);
+ out_buffer[i*3+1] = RGB565toG8(in_buffer[i]);
+ out_buffer[i*3+2] = RGB565toB8(in_buffer[i]);
+ }
+ write(1, out_buffer, (size/2)*3);
+ if (remain)
+ memcpy(in_buffer, in_buffer+(size-remain), remain);
+ }
+ free(in_buffer);
+ free(out_buffer);
+}
+
+void convert888to565(void) {
+ unsigned char* in_buffer = (unsigned char*)malloc(RGB888_BUFSIZE);
+ unsigned short* out_buffer = (unsigned short*)malloc(RGB565_BUFSIZE);
+ int i = 0;
+ int size = 0;
+ int remain = 0;
+ char buf[256];
+
+ while (1) {
+ size = read(0, in_buffer + remain, RGB888_BUFSIZE - remain);
+ if (size < 1 && remain == 0)
+ break;
+ size += remain;
+ remain = size - (size/3)*3;
+ for (i = 0; i < size/3; ++i)
+ out_buffer[i] = RGB888toRGB565(in_buffer[i*3], in_buffer[i*3+1], in_buffer[i*3+2]);
+
+ write(1, out_buffer, (size/3)*2);
+ if (remain)
+ memcpy(in_buffer, in_buffer+(size-remain), remain);
+ }
+ free(in_buffer);
+ free(out_buffer);
+}
+
+int main(int argc, char **argv)
+{
+ if (argc == 2) // Any argument will kick it into reverse
+ convert565to888();
+ else
+ convert888to565();
+ return 0;
+}
diff --git a/static/index.css b/static/css/index.css
similarity index 100%
rename from static/index.css
rename to static/css/index.css
diff --git a/static/index.html b/static/index.html
index 4600521..1d892fa 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1,10 +1,13 @@
PhotoFrame
-
-
-
-
+
+
+
+
+
+
+
diff --git a/update.sh b/update.sh
index c153e85..2bcfac3 100755
--- a/update.sh
+++ b/update.sh
@@ -39,6 +39,15 @@ if [ "$1" = "post" ]; 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
+
+ # Copy new service and reload systemd
cp frame.service /etc/systemd/system/
systemctl daemon-reload