diff --git a/Dockerfile.template b/Dockerfile.template index a00663d..ce81e1d 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -1,67 +1,7 @@ # Base image -FROM resin/%%RESIN_MACHINE_NAME%%-node:0.10 - -MAINTAINER Benoit Guigal - -# Make sure installation is not asking for prompt -ENV DEBIAN_FRONTEND noninteractive - -RUN apt-get update && apt-get install -y \ - python \ - python-dev \ - python-setuptools \ - python-pip \ - pkg-config \ - gcc \ - g++ \ - make \ - build-essential \ - tcl8.5 \ - unzip \ - tar \ - wget \ - bzip2 \ - libjpeg-dev \ - zlib1g-dev \ - libpng12-dev \ - usbutils \ - libfreetype6 \ - libfontconfig \ - git \ - bind9 \ - bridge-utils \ - connman \ - iptables \ - libdbus-1-dev \ - libexpat-dev \ - net-tools \ - wireless-tools \ - sysstat \ - procmail \ - vim \ - && rm -rf /var/lib/apt/lists/* - - -RUN wget --no-check-certificate https://github.com/gonzalo/gphoto2-updater/archive/2.5.10.zip && \ - unzip 2.5.10.zip && \ - cd gphoto2-updater-2.5.10 && \ - chmod +x gphoto2-updater.sh && \ - ./gphoto2-updater.sh - - -RUN wget --no-check-certificate https://github.com/Postcard/python-epson-printer/archive/v1.9.0.zip && \ - unzip v1.9.0.zip && \ - rm v1.9.0.zip && \ - cd python-epson-printer-1.9.0 && \ - python setup.py install - - -RUN wget --no-check-certificate https://github.com/aeberhardo/phantomjs-linux-armv6l/archive/master.zip && \ - unzip master.zip && \ - rm master.zip && \ - cd phantomjs-linux-armv6l-master && \ - bunzip2 *.bz2 && \ - tar xf *.tar +FROM figure/%%RESIN_MACHINE_NAME%%:1.1.0 + +MAINTAINER Benoit Guigal RUN git clone https://github.com/Postcard/png2pos.git && \ cd png2pos && \ @@ -70,49 +10,27 @@ RUN git clone https://github.com/Postcard/png2pos.git && \ git submodule update && \ make install -RUN pip install gphoto2==1.4.1 \ - gpiozero==1.2.0 \ - Pillow==3.1.0 \ - pifacecommon==4.1.2 \ - pifacedigitalio==3.0.5 \ - pytz==2015.2 \ - supervisor==3.1.3 \ - hashids==1.1.0 \ - ticketrenderer==0.2.0 \ - figure-sdk==0.2.0 \ - peewee==2.8.1 \ - Flask==0.11.1 \ - psutil==4.3.0 \ - netifaces==0.10.4 \ - piexif==1.0.5 - -ENV LANG C.UTF-8 -ENV C_FORCE_ROOT true +# Install dependencies for python-webkit2png +RUN apt-get update && apt-get install python-qt4 \ + libqt4-webkit \ + xvfb \ + xauth + +# Add xvfb to init.d +ADD xvfb /etc/init.d/xvfb +RUN chmod +x /etc/init.d/xvfb +ENV DISPLAY :1 + +# Install Python dependencies +RUN mkdir requirements +ADD requirements ./requirements +RUN cd requirements && pip install -r prod.txt + ENV FIGURE_DIR /figure/figureraspbian ENV IMAGE_DIR /data/images -ENV PHANTOMJS_PATH /phantomjs-linux-armv6l-master/phantomjs-1.9.0-linux-armv6l/bin/phantomjs ENV DATA_ROOT /data ENV STATIC_ROOT /data/static ENV MEDIA_ROOT /data/media -ENV ZEO_SOCKET /data/zeo.sock - -COPY ./wifi-connect/assets/bind /etc/bind -RUN mkdir -p /usr/src/app/ -WORKDIR /usr/src/app -COPY ./wifi-connect/package.json ./ -RUN JOBS=MAX npm install --unsafe-perm --production && npm cache clean - -COPY ./wifi-connect/bower.json ./wifi-connect/.bowerrc ./ -RUN ./node_modules/.bin/bower --allow-root install \ - && ./node_modules/.bin/bower --allow-root cache clean - -COPY ./wifi-connect/. ./ -RUN ./node_modules/.bin/coffee -c ./src - -RUN touch /var/log/named.log -RUN chown bind /var/log/named.log - -VOLUME /var/lib/connman RUN mkdir -p /usr/share/fonts/opentype COPY fonts/*.otf /usr/share/fonts/opentype/ diff --git a/Makefile b/Makefile index bb3444a..b67626b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,2 @@ test: - # This runs all of the tests. To run an individual test, run py.test with - # the -k flag, like "py.test -k test_processus" - py.test test_figure.py \ No newline at end of file + python -m unittest discover diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 0000000..3f91f01 --- /dev/null +++ b/fabfile.py @@ -0,0 +1,28 @@ + +from fabric.api import task, env +from fabric.operations import local + +env.force = False + +@task +def integration(): + env.remotes = ['resinintegration_raspi2', 'resinintegration_raspi3'] + +@task +def production(): + env.remotes = ['resinproduction_raspi2', 'resinproduction_raspi3'] + +@task +def force(): + env.force = True + +@task +def deploy(branch="master"): + for remote in env.remotes: + cmd = ['git', 'push'] + if env.force: + cmd.append('--force') + cmd.append(remote) + cmd.append('%s:master' % branch) + cmd = ' '.join(cmd) + local(cmd) diff --git a/figureraspbian/__main__.py b/figureraspbian/__main__.py index c1d0030..14ce71c 100644 --- a/figureraspbian/__main__.py +++ b/figureraspbian/__main__.py @@ -1,8 +1,20 @@ # -*- coding: utf8 -*- import signal +import os +import logging + +import requests + +from app import App +from db import db +from devices.button import Button +import settings +from models import get_all_models, Photobooth + + +logging.basicConfig(format=settings.LOG_FORMAT, datefmt='%Y.%m.%d %H:%M:%S', level='INFO') -from figureraspbian.app import App class GracefulKiller: @@ -16,13 +28,36 @@ def __init__(self, app): def exit_gracefully(self, signum, frame): self.kill_now = True self.app.stop() + db.close_db() + + +class ShutdownHook: + + def __init__(self): + self.shutdown_button = Button.factory(settings.SHUTDOWN_PIN, 0.05, 10) + self.shutdown_button.when_pressed = self.shutdown + + def shutdown(self): + # Call resin.io supervisor shutdown endpoint https://docs.resin.io/runtime/supervisor-api/#post-v1-shutdown + resin_supervisor_address = settings.RESIN_SUPERVISOR_ADDRESS + resin_supervisor_api_key = settings.RESIN_SUPERVISOR_API_KEY + shutdown_url = "%s/v1/shutdown?apikey=%s" % (resin_supervisor_address, resin_supervisor_api_key) + requests.post(shutdown_url, data={'force': True}) + +def create_tables(): + db.connect_db() + # creates tables if not exist + db.database.create_tables(get_all_models(), True) if __name__ == '__main__': + create_tables() + Photobooth.get_or_create(uuid=settings.RESIN_UUID) app = App() killer = GracefulKiller(app) app.start() + shutdown_hook = ShutdownHook() signal.pause() diff --git a/figureraspbian/api.py b/figureraspbian/api.py index 2bb484d..38fad18 100644 --- a/figureraspbian/api.py +++ b/figureraspbian/api.py @@ -1,14 +1,17 @@ # -*- coding: utf8 -*- -from os.path import basename, dirname, join from functools import wraps +import cStringIO -from flask import Flask, send_from_directory, request, abort, jsonify +from flask import Flask, send_from_directory, request, jsonify, send_file import psutil from PIL import Image -from figureraspbian import photobooth -from figureraspbian import db, settings, utils -from figureraspbian.exceptions import DevicesBusy, OutOfPaperError + +from threads import rlock +from photobooth import get_photobooth +from models import Photobooth, Portrait +import settings +from exceptions import DevicesBusy, PhotoboothNotReady, OutOfPaperError app = Flask(__name__) @@ -25,14 +28,32 @@ def decorated_function(*args, **kwargs): return decorated_function +@app.route('/focus', methods=['POST']) +@login_required +def focus(): + try: + steps = request.values.get('focus_steps') + photobooth = get_photobooth() + if steps: + photobooth.focus_camera(int(steps)) + else: + photobooth.focus_camera() + return jsonify(message='Camera focused') + except DevicesBusy: + return jsonify(error='the photobooth is busy'), 423 + + @app.route('/trigger', methods=['POST']) @login_required def trigger(): try: - ticket_path = photobooth._trigger() - return send_from_directory(dirname(ticket_path), basename(ticket_path)) + photobooth = get_photobooth() + ticket = photobooth.trigger() + return send_file(cStringIO.StringIO(ticket)) except DevicesBusy: return jsonify(error='the photobooth is busy'), 423 + except PhotoboothNotReady: + return jsonify(erro='the photobooth is not ready or not initialized properly'), 423 ALLOWED_EXTENSIONS = ['jpg', 'JPEG', 'JPG', 'png', 'PNG', 'gif'] @@ -53,8 +74,8 @@ def test_template(): w, h = picture.size if w != h: return jsonify(error='The picture must have a square shape'), 400 - exif_bytes = picture.info['exif'] if 'exif' in picture.info else None - photobooth.render_print_and_upload(picture, exif_bytes) + photobooth = get_photobooth() + photobooth.render_print_and_upload(picture_file.getvalue()) return jsonify(message='Ticket successfully printed') @@ -62,22 +83,13 @@ def test_template(): @login_required def print_image(): """ Print the image uploaded by the user """ - image_file = request.files['image'] if image_file and allowed_file(image_file.filename): - im = Image.open(image_file) - w, h = im.size - if w != settings.PRINTER_MAX_WIDTH: - ratio = float(settings.PRINTER_MAX_WIDTH) / w - im = im.resize((settings.PRINTER_MAX_WIDTH, int(h * ratio))) - if im.mode != '1': - im = im.convert('1') - im_path = join(settings.MEDIA_ROOT, 'test.png') - im.save(im_path, im.format, quality=100) - pos_data = utils.png2pos(im_path) - try: - photobooth.printer.print_ticket(pos_data) + photobooth = get_photobooth() + photobooth.print_image(image_file.getvalue()) + except DevicesBusy: + return jsonify(error='the photobooth is busy'), 423 except OutOfPaperError: return jsonify(error='Out of paper'), 500 return jsonify(message='Ticket printed succesfully') @@ -86,30 +98,23 @@ def print_image(): @app.route('/door_open', methods=['POST']) @login_required def door_open(): - photobooth.door_open() + photobooth = get_photobooth() + photobooth.unlock_door() return jsonify(message='Door opened') -@app.route('/logs') -@login_required -def logs(): - resp = send_from_directory('/data/log', 'figure.log') - resp.headers['Content-Disposition'] = 'attachment; filename="figure.log"' - return resp - - @app.route('/info') @login_required def info(): - photobooth = db.get_photobooth() + photobooth = Photobooth.get() place = photobooth.place.name if photobooth.place else '' identifier = photobooth.serial_number or settings.RESIN_UUID - number_of_portraits_to_be_uploaded = db.get_portrait_to_be_uploaded() or 0 + portraits_not_uploaded_count = Portrait.not_uploaded_count() res = { 'identifier': identifier, 'place': place, 'counter': photobooth.counter, - 'number_of_portraits_to_be_uploaded': number_of_portraits_to_be_uploaded + 'number_of_portraits_to_be_uploaded': portraits_not_uploaded_count } return jsonify(**res) @@ -133,7 +138,7 @@ def system(): @app.route('/acquire_lock', methods=['POST']) @login_required def acquire_lock(): - acquired = photobooth.lock.acquire(False) + acquired = rlock.acquire(False) if acquired: return jsonify(message='Lock acquired') else: @@ -144,7 +149,7 @@ def acquire_lock(): @login_required def release_lock(): try: - photobooth.lock.release() + rlock.release() except Exception: pass finally: diff --git a/figureraspbian/app.py b/figureraspbian/app.py index 1e68989..4123799 100644 --- a/figureraspbian/app.py +++ b/figureraspbian/app.py @@ -1,51 +1,96 @@ # -*- coding: utf8 -*- import logging +from .threads import Interval, rlock +import socket + +import settings +from devices.button import Button +from devices.real_time_clock import RTC +from api import start_server +from exceptions import OutOfPaperError +from photobooth import get_photobooth + +from request import is_online, download_booting_ticket_template, download_ticket_stylesheet, update, upload_portraits +from request import claim_new_codes, update_mac_addresses_async +from utils import set_system_time -from figureraspbian import photobooth, settings -from figureraspbian.devices.button import PiFaceDigitalButton -from figureraspbian import db -from figureraspbian.api import start_server -from figureraspbian.exceptions import OutOfPaperError logger = logging.getLogger(__name__) class App(object): - """ - Registers button callbacks and make sure resources are correctly initialized and closed - """ + """ Registers button callbacks and make sure resources are correctly initialized and closed """ def __init__(self): - db.init() - photobooth.initialize() - self.button = PiFaceDigitalButton(settings.BUTTON_PIN, 0.05, settings.DOOR_OPENING_DELAY) + + if is_online(): + try: + download_ticket_stylesheet() + download_booting_ticket_template() + except Exception as e: + logger.exception(e) + + try: + update() + except Exception as e: + logger.exception(e) + + update_mac_addresses_async() + + try: + claim_new_codes() + except Exception as e: + logger.exception(e) + else: + rtc = RTC.factory() + if rtc: + try: + hc_dt = rtc.read_datetime() + set_system_time(hc_dt) + except Exception as e: + logger.exception(e) + self.photobooth = get_photobooth() + self.button = Button.factory(settings.BUTTON_PIN, 0.05, settings.DOOR_OPENING_DELAY) self.button.when_pressed = self.when_pressed self.button.when_held = self.when_held - self.intervals = photobooth.set_intervals() + self.intervals = set_intervals() def when_pressed(self): - photobooth.trigger_async() + self.photobooth.trigger_async() def when_held(self): - photobooth.door_open() + self.photobooth.unlock_door() def start(self): self.button.start() try: - photobooth.print_booting_ticket() + self.photobooth.print_booting_ticket() except OutOfPaperError: pass logger.info("Ready...") - start_server() + try: + start_server() + except socket.error as e: + logger.exception(e) def stop(self): - db.close() for interval in self.intervals: interval.stop() self.button.close() # wait for a trigger to complete before exiting - photobooth.lock.acquire() + rlock.acquire() logger.info("Bye Bye") +def set_intervals(): + """ Start tasks that are run in the background at regular intervals """ + intervals = [ + Interval(update, settings.UPDATE_POLL_INTERVAL), + Interval(upload_portraits, settings.UPLOAD_PORTRAITS_INTERVAL), + Interval(claim_new_codes, settings.CLAIM_NEW_CODES_INTERVAL) + ] + + for interval in intervals: + interval.start() + return intervals \ No newline at end of file diff --git a/figureraspbian/constants.py b/figureraspbian/constants.py new file mode 100644 index 0000000..9c09a4f --- /dev/null +++ b/figureraspbian/constants.py @@ -0,0 +1,11 @@ + + +EPSON_VENDOR_ID = '04b8' + +TMT20_PRODUCT_ID = '0e03' + +TMT20II_PRODUCT_ID = '0e15' + +CUSTOM_VENDOR_ID = '0dd4' + +VKP80III_PRODUCT_ID = '0205' diff --git a/figureraspbian/db.py b/figureraspbian/db.py index 68bc22c..4307200 100644 --- a/figureraspbian/db.py +++ b/figureraspbian/db.py @@ -1,369 +1,36 @@ -from peewee import * -from os.path import join, basename +# -*- coding: utf8 -*- -from figureraspbian import settings -from figureraspbian.utils import pixels2cm, download +from peewee import Model, SqliteDatabase +import settings -database = SqliteDatabase(join(settings.DATA_ROOT, 'local.db')) +class Database(object): + def __init__(self, database=None): -def get_tables(): - return [ - Place, - Event, - TicketTemplate, - TextVariable, - Text, - ImageVariable, - Image, - Photobooth, - Code, - Portrait] + self.database = database + if self.database is None: + self.load_database() -def init(): - database.connect() - # creates tables if not exist - database.create_tables(get_tables(), True) - # always create a record for photobooth - Photobooth.get_or_create(uuid=settings.RESIN_UUID) - database.close() + self.Model = self.get_model_class() + def load_database(self): + self.database = SqliteDatabase(settings.SQLITE_FILEPATH) -def erase(): - database.drop_tables(get_tables(), True) + def get_model_class(self): + class BaseModel(Model): + class Meta: + database = self.database + return BaseModel -def close(): - database.close() + def connect_db(self): + self.database.connect() + def close_db(self): + if not self.database.is_closed(): + self.database.close() -class BaseModel(Model): - class Meta: - database = database - - -class Place(BaseModel): - - name = CharField() - tz = CharField(default='Europe/Paris') - modified = CharField() - - -class Event(BaseModel): - - name = CharField() - modified = CharField() - - -class TicketTemplate(BaseModel): - - html = TextField() - title = TextField(null=True) - description = TextField(null=True) - modified = CharField() - - def serialize(self): - data = self.__dict__['_data'] - data['images'] = [image.serialize() for image in self.images] - data['text_variables'] = [text_variable.serialize() for text_variable in self.text_variables] - data['image_variables'] = [image_variable.serialize() for image_variable in self.image_variables] - return data - - -class TextVariable(BaseModel): - - name = CharField() - ticket_template = ForeignKeyField(TicketTemplate, null=True, related_name='text_variables') - mode = CharField() - - def serialize(self): - data = { - 'id': self.id, - 'name': self.name, - 'mode': self.mode, - 'items': [text.serialize() for text in self.items] - } - return data - - -class Text(BaseModel): - - value = TextField() - variable = ForeignKeyField(TextVariable, related_name='items', null=True) - - def serialize(self): - return {'id': self.id, 'text': self.value} - - -class ImageVariable(BaseModel): - - name = CharField() - ticket_template = ForeignKeyField(TicketTemplate, null=True, related_name='image_variables') - mode = CharField() - - def serialize(self): - data = { - 'id': self.id, - 'name': self.name, - 'mode': self.mode, - 'items': [image.serialize() for image in self.items] - } - return data - - -class Image(BaseModel): - - path = TextField() - variable = ForeignKeyField(ImageVariable, related_name='items', null=True) - ticket_template = ForeignKeyField(TicketTemplate, related_name='images', null=True) - - def serialize(self): - return {'id': self.id, 'name': basename(self.path)} - - -class Photobooth(BaseModel): - - uuid = CharField(unique=True) - serial_number = CharField(null=True) - place = ForeignKeyField(Place, null=True) - event = ForeignKeyField(Event, null=True) - ticket_template = ForeignKeyField(TicketTemplate, null=True) - paper_level = FloatField(default=100.0) - counter = IntegerField(default=0) - - -class Code(BaseModel): - - value = CharField() - - -class Portrait(BaseModel): - - code = CharField() - taken = DateTimeField() - place_id = CharField(null=True) - event_id = CharField(null=True) - photobooth_id = CharField() - ticket = CharField() - picture = CharField() - uploaded = BooleanField(default=False) - - -def get_photobooth(): - return Photobooth.get(Photobooth.uuid == settings.RESIN_UUID) - - -def get_code(): - code = Code.select().limit(1)[0] - value = code.value - code.delete_instance() - return value - - -def get_number_of_portraits_to_be_uploaded(): - """ Retrieves the number of portraits to be uploaded """ - return Portrait.select().where(~ Portrait.uploaded).count() - - -def get_portrait_to_be_uploaded(): - """ return first portrait to be uploaded """ - try: - return Portrait.select().where(~ Portrait.uploaded).get() - except Portrait.DoesNotExist: - return None - - -def get_portraits_to_be_uploaded(): - return Portrait.select().filter(uploaded=False) - - -def create_portrait(portrait): - return Portrait.create( - code=portrait['code'], - taken=portrait['taken'], - place_id=portrait['place'], - event_id=portrait['event'], - photobooth_id=portrait['photobooth'], - ticket=portrait['ticket'], - picture=portrait['picture'] - ) - - -def create_place(place): - return Place.create( - id=place['id'], - name=place.get('name'), - tz=place.get('tz'), - modified=place.get('modified') - ) - - -def create_event(event): - return Event.create( - id=event['id'], - name=event.get('name'), - modified=event.get('modified') - ) - - -def update_or_create_text(text, variable=None): - try: - txt = Text.get(Text.id == text['id']) - if txt.value != text['text']: - txt.value = text['text'] - txt.save() - return txt - except Text.DoesNotExist: - return Text.create(id=text['id'], value=text['text'], variable=variable) - - -def update_or_create_text_variable(text_variable, ticket_template=None): - try: - tv = TextVariable.get(TextVariable.id == text_variable['id']) - if tv.name != text_variable.get('name') or tv.mode != text_variable.get('mode'): - tv.name = text_variable.get('name') - tv.mode = text_variable.get('mode') - tv.save() - text_ids = [item['id'] for item in text_variable['items']] - query = Text.select().where(~(Text.id << text_ids)).join(TextVariable).where(TextVariable.id == text_variable['id']) - for text in query: - text.delete_instance() - except TextVariable.DoesNotExist: - tv = TextVariable.create( - id=text_variable['id'], - name=text_variable.get('name'), - mode=text_variable.get('mode'), - ticket_template=ticket_template) - for text in text_variable['items']: - update_or_create_text(text, tv) - return tv - - -def update_or_create_image(image, variable=None, ticket_template=None): - try: - img = Image.get(Image.id == image['id']) - if basename(img.path) != image['name']: - path = download(image['image'], settings.IMAGE_ROOT) - img.path = path - img.save() - return img - except Image.DoesNotExist: - path = download(image['image'], settings.IMAGE_ROOT) - return Image.create(id=image['id'], path=path, variable=variable, ticket_template=ticket_template) - - -def update_or_create_image_variable(image_variable, ticket_template=None): - try: - iv = ImageVariable.get(ImageVariable.id == image_variable['id']) - if iv.name != image_variable.get('name') or iv.mode != image_variable.get('mode'): - iv.name = image_variable.get('name') - iv.mode = image_variable.get('mode') - iv.save() - image_ids = [item['id'] for item in image_variable['items']] - query = Image.select().where(~(Image.id << image_ids)).join(ImageVariable).where(ImageVariable.id == image_variable['id']) - for image in query: - image.delete_instance() - except ImageVariable.DoesNotExist: - iv = ImageVariable.create( - id=image_variable['id'], - name=image_variable.get('name'), - mode=image_variable.get('mode'), - ticket_template=ticket_template) - for image in image_variable['items']: - update_or_create_image(image, variable=iv) - return iv - - -def update_or_create_ticket_template(ticket_template): - - try: - tt = TicketTemplate.get(TicketTemplate.id == ticket_template['id']) - tt.html = ticket_template['html'] - tt.title = ticket_template['title'] - tt.description = ticket_template['description'] - tt.modified = ticket_template['modified'] - tt.save() - except TicketTemplate.DoesNotExist: - tt = TicketTemplate.create( - id=ticket_template['id'], - html=ticket_template['html'], - title=ticket_template['title'], - description=ticket_template['description'], - modified=ticket_template['modified'] - ) - - for text_variable in ticket_template['text_variables']: - update_or_create_text_variable(text_variable, tt) - - for image_variable in ticket_template['image_variables']: - update_or_create_image_variable(image_variable, tt) - - for image in ticket_template['images']: - update_or_create_image(image, ticket_template=tt) - - return tt - - -def update_photobooth(**kwargs): - query = Photobooth.update(**kwargs).where(Photobooth.uuid == settings.RESIN_UUID) - query.execute() - - -def update_place(id, **kwargs): - query = Place.update(**kwargs).where(Place.id == id) - query.execute() - - -def update_event(id, **kwargs): - query = Event.update(**kwargs).where(Event.id == id) - query.execute() - - -def update_portrait(id, **kwargs): - query = Portrait.update(**kwargs).where(Portrait.id == id) - query.execute() - - -def increment_counter(): - photobooth = get_photobooth() - photobooth.counter += 1 - photobooth.save() - - -def update_paper_level(pixels): - - photobooth = Photobooth.get(Photobooth.uuid == settings.RESIN_UUID) - if pixels == 0: - # we are out of paper - new_paper_level = 0 - else: - old_paper_level = photobooth.paper_level - if old_paper_level == 0: - # Someone just refill the paper - new_paper_level = 100 - else: - cm = pixels2cm(pixels) - new_paper_level = old_paper_level - (cm / float(settings.PAPER_ROLL_LENGTH)) * 100 - if new_paper_level <= 1: - # estimate is wrong, guess it's 10% - new_paper_level = 10 - photobooth.paper_level = new_paper_level - photobooth.save() - return new_paper_level - - -def delete(instance): - return instance.delete_instance() - - -def should_claim_code(): - return Code.select().count() < 1000 - - -def bulk_insert_codes(codes): - with database.atomic(): - for code in codes: - Code.create(value=code) +db = Database() diff --git a/figureraspbian/decorators.py b/figureraspbian/decorators.py index a60abd7..7409847 100644 --- a/figureraspbian/decorators.py +++ b/figureraspbian/decorators.py @@ -1,7 +1,7 @@ # -*- coding: utf8 -*- -from figureraspbian.db import database -from figureraspbian.exceptions import DevicesBusy +from exceptions import DevicesBusy + def execute_if_not_busy(lock): """ @@ -17,17 +17,4 @@ def decorated(*args, **kwargs): else: raise DevicesBusy() return decorated - return wrap - - -def connection_managed(f): - """ - This decorator ensures the database connection is opened before execution and closed after execution - """ - def wrap(f): - database.connect() - try: - f() - finally: - database.close() return wrap \ No newline at end of file diff --git a/figureraspbian/devices/button.py b/figureraspbian/devices/button.py index 4100211..db1850e 100644 --- a/figureraspbian/devices/button.py +++ b/figureraspbian/devices/button.py @@ -5,7 +5,11 @@ import logging from pifacedigitalio import PiFaceDigital -from figureraspbian.threads import StoppableThread +import gpiozero + +from ..threads import StoppableThread +from .. import settings +from .. exceptions import InvalidIOInterfaceError logger = logging.getLogger(__name__) @@ -105,6 +109,15 @@ def close(self): self._event_thread.stop() self._hold_thread.stop() + def factory(*args, **kwargs): + if settings.IO_INTERFACE == 'PIFACE': + return PiFaceDigitalButton(*args, **kwargs) + elif settings.IO_INTERFACE == 'GPIOZERO': + return GPIOZeroButton(*args, **kwargs) + raise InvalidIOInterfaceError() + + factory = staticmethod(factory) + class PiFaceDigitalButton(Button): """ @@ -120,6 +133,17 @@ def value(self): return self.pifacedigital.input_pins[self.pin].value +class GPIOZeroButton(Button): + """ Represents a button whose value is determined using the GPIOZero library """ + + def __init__(self, *args, **kwargs): + super(GPIOZeroButton, self).__init__(*args, **kwargs) + self.device = gpiozero.Button(self.pin) + + def value(self): + return self.device.is_pressed + + class EventThread(StoppableThread): """ Provides a background thread that repeatedly check for button edges events (activated or deactivated) diff --git a/figureraspbian/devices/camera.py b/figureraspbian/devices/camera.py index afe7cfd..cd914f7 100644 --- a/figureraspbian/devices/camera.py +++ b/figureraspbian/devices/camera.py @@ -3,21 +3,34 @@ import os import time from contextlib import contextmanager +import cStringIO +import logging import gphoto2 as gp -from pifacedigitalio import PiFaceDigital +from PIL import Image +import piexif -from figureraspbian import settings -from figureraspbian.utils import timeit, crop_to_square +from .. import settings +from ..utils import timeit, crop_to_square +from .remote_release_connector import RemoteReleaseConnector +from ..exceptions import TimeoutWaitingForFileAdded -EOS_1200D_CONFIG = { +logger = logging.getLogger(__name__) + + +CAMERA_CONFIG = { + 'reviewtime': 0, 'capturetarget': 1, - 'focusmode': 3, 'imageformat': 6, + 'imageformatsd': 6, + 'picturestyle': 1, + 'eosremoterelease': 0, + 'whitebalance': settings.WHITE_BALANCE, 'aperture': settings.APERTURE, 'shutterspeed': settings.SHUTTER_SPEED, - 'iso': settings.ISO} + 'iso': settings.ISO +} @contextmanager @@ -30,38 +43,30 @@ def open_camera(): gp.check_result(gp.gp_camera_exit(camera, context)) -def Camera(): - """ Factory to create a camera """ - if settings.CAMERA_TRIGGER_TYPE == 'REMOTE_RELEASE_CONNECTOR': - return RemoteReleaseConnectorDSLRCamera() - return DSLRCamera() - - -class DSLRCamera(object): +class Camera(object): """ - Digital Single Lens Reflex camera - It uses gphoto2 to communicate with the digital camera: + Represents a digital camera that can be controlled with libgphoto2 http://www.gphoto.org/proj/libgphoto2/ Lists of supported cameras: http://www.gphoto.org/proj/libgphoto2/support.php """ - def __init__(self): + def __init__(self, *args, **kwargs): + super(Camera, self).__init__(*args, **kwargs) + self.configure() + def _trigger(self, camera, context): + return gp.check_result(gp.gp_camera_capture(camera, gp.GP_CAPTURE_IMAGE, context)) + + def configure(self): with open_camera() as (camera, context): config = gp.check_result(gp.gp_camera_get_config(camera, context)) - for param, choice in EOS_1200D_CONFIG.iteritems(): + for param, choice in CAMERA_CONFIG.iteritems(): widget = gp.check_result(gp.gp_widget_get_child_by_name(config, param)) value = gp.check_result(gp.gp_widget_get_choice(widget, choice)) gp.gp_widget_set_value(widget, value) gp.gp_camera_set_config(camera, config, context) - self._clear_space(camera, context) - - def _trigger(self, camera, context): - return gp.check_result(gp.gp_camera_capture(camera, gp.GP_CAPTURE_IMAGE, context)) - - @timeit def capture(self): with open_camera() as (camera, context): @@ -80,7 +85,20 @@ def capture(self): file_data = gp.check_result(gp.gp_file_get_data_and_size(camera_file)) - return crop_to_square(file_data) + picture = Image.open(cStringIO.StringIO(file_data)) + exif_dict = piexif.load(picture.info["exif"]) + cropped = crop_to_square(picture) + + s, _ = cropped.size + exif_dict["Exif"][piexif.ExifIFD.PixelXDimension] = s + exif_bytes = piexif.dump(exif_dict) + + buf = cStringIO.StringIO() + cropped.save(buf, "JPEG", exif=exif_bytes) + cropped = buf.getvalue() + buf.close() + + return cropped def _clear_space(self, camera, context): files = self._list_files(camera, context) @@ -122,35 +140,81 @@ def list_files(self, path='/'): with open_camera() as (camera, context): return self._list_files(camera, context, path) + def focus_further(self, steps): + with open_camera() as (camera, context): + config = gp.check_result(gp.gp_camera_get_config(camera, context)) + widget = gp.check_result(gp.gp_widget_get_child_by_name(config, 'viewfinder')) + gp.gp_widget_set_value(widget, 1) + gp.gp_camera_set_config(camera, config, context) + time.sleep(0.5) + self._focus_further(steps, camera, config, context) + gp.gp_widget_set_value(widget, 1) + gp.gp_camera_set_config(camera, config, context) -class RemoteReleaseConnector: - """ - Represents a remote release connector. http://www.doc-diy.net/photo/remote_pinout/ - In our case the cable is just a 2.5mm jack - """ - - def __init__(self, pin=settings.CAMERA_REMOTE_RELEASE_CONNECTOR_PIN): - self.pifacedigital = PiFaceDigital() - self.pin = pin + def _focus_further(self, steps, camera, config, context): + for i in range(0, steps): + self._change_focus(1, camera, config, context) - def trigger(self): - self.pifacedigital.relays[self.pin].turn_on() - time.sleep(0.1) - self.pifacedigital.relays[self.pin].turn_off() + def focus_nearer(self, steps): + with open_camera() as (camera, context): + config = gp.check_result(gp.gp_camera_get_config(camera, context)) + widget = gp.check_result(gp.gp_widget_get_child_by_name(config, 'viewfinder')) + gp.gp_widget_set_value(widget, 1) + gp.gp_camera_set_config(camera, config, context) + time.sleep(0.5) + self._focus_nearer(steps, camera, config, context) + gp.gp_widget_set_value(widget, 1) + gp.gp_camera_set_config(camera, config, context) + def _focus_nearer(self, steps, camera, config, context): + for i in range(0, steps): + self._change_focus(0, camera, config, context) + + def _change_focus(self, direction, camera, config, context): + """ + :param direction: 1 further, 0 nearer + """ + widget = gp.check_result(gp.gp_widget_get_child_by_name(config, 'manualfocusdrive')) + value = gp.check_result(gp.gp_widget_get_choice(widget, 6 if direction else 2)) + gp.gp_widget_set_value(widget, value) + gp.gp_camera_set_config(camera, config, context) + value = gp.check_result(gp.gp_widget_get_choice(widget, 3)) + gp.gp_widget_set_value(widget, value) + gp.gp_camera_set_config(camera, config, context) + + def focus(self, steps=settings.CAMERA_FOCUS_STEPS): + with open_camera() as (camera, context): + config = gp.check_result(gp.gp_camera_get_config(camera, context)) + widget = gp.check_result(gp.gp_widget_get_child_by_name(config, 'viewfinder')) + gp.gp_widget_set_value(widget, 1) + gp.gp_camera_set_config(camera, config, context) + time.sleep(0.5) + # focus is relative so we need to focus the furthest possible before adjusting + self._focus_further(80, camera, config, context) + self._focus_nearer(steps, camera, config, context) + gp.gp_widget_set_value(widget, 1) + gp.gp_camera_set_config(camera, config, context) -class TimeoutWaitingForFileAdded(Exception): - pass + @classmethod + def factory(cls, *args, **kwargs): + try: + if settings.CAMERA_TRIGGER_TYPE == 'GPHOTO2': + return cls(*args, **kwargs) + elif settings.CAMERA_TRIGGER_TYPE == 'REMOTE_RELEASE_CONNECTOR': + return RemoteReleaseConnectorCamera(*args, **kwargs) + except Exception as e: + logger.error(e.message) + return None -class RemoteReleaseConnectorDSLRCamera(DSLRCamera): +class RemoteReleaseConnectorCamera(Camera): """ Represents a camera that is triggered with a remote release connector """ - def __init__(self): - super(RemoteReleaseConnectorDSLRCamera, self).__init__() - self.remote_release_connector = RemoteReleaseConnector() + def __init__(self, *args, **kwargs): + super(RemoteReleaseConnectorCamera, self).__init__(*args, **kwargs) + self.remote_release_connector = RemoteReleaseConnector.factory(settings.REMOTE_RELEASE_CONNECTOR_PIN) def _trigger(self, camera, context): self.remote_release_connector.trigger() diff --git a/figureraspbian/devices/door_lock.py b/figureraspbian/devices/door_lock.py index 41f0c7b..d2b311e 100644 --- a/figureraspbian/devices/door_lock.py +++ b/figureraspbian/devices/door_lock.py @@ -1,12 +1,38 @@ + from pifacedigitalio import PiFaceDigital +import gpiozero + +from .. import settings +from ..exceptions import InvalidIOInterfaceError -class PiFaceDigitalDoorLock(object): +class DoorLock(object): """ Represents an electrical lock such as this one https://www.amazon.fr/gp/product/B005FOTJF8/ When the current is passing, the lock is opened. When the current is not passing the lock is closed. It is used to control the opening of a door that keep the devices safe + """ + + def open(self): + raise NotImplementedError() + + def close(self): + raise NotImplementedError() + + def factory(*args, **kwargs): + if settings.IO_INTERFACE == 'PIFACE': + return PiFaceDigitalDoorLock(*args, **kwargs) + elif settings.IO_INTERFACE == 'GPIOZERO': + return GPIOZeroDoorLock() + else: + raise InvalidIOInterfaceError() + + factory = staticmethod(factory) + + +class PiFaceDigitalDoorLock(DoorLock): + """ In this implementation, the current from a 12V AC/DC converter is controlled via a PiFaceDigital relay """ @@ -20,3 +46,17 @@ def open(self): def close(self): self.pifacedigital.relays[self.pin].turn_off() + +class GPIOZeroDoorLock(DoorLock): + """ + In this implementation, the current from a 12V AC/DC converter is controlled via an external relay driven by a gpio + """ + + def __init__(self, pin=0): + self.device = gpiozero.OutputDevice(pin) + + def open(self): + self.device.on() + + def close(self): + self.device.off() diff --git a/figureraspbian/devices/printer.py b/figureraspbian/devices/printer.py index 3969c34..e8dd900 100644 --- a/figureraspbian/devices/printer.py +++ b/figureraspbian/devices/printer.py @@ -1,55 +1,189 @@ # -*- coding: utf8 -*- -import re import subprocess +import os +from os.path import join +import cStringIO +import logging from usb.core import USBError -from figureraspbian import settings -from figureraspbian.utils import timeit -from figureraspbian.exceptions import OutOfPaperError - from epson_printer import epsonprinter +from custom_printer import printers as customprinters +from custom_printer import utils as custom_printer_utils +from PIL import Image + +from .. import settings +from ..utils import timeit, get_usb_devices, add_margin, resize_preserve_ratio +from ..exceptions import OutOfPaperError, PrinterNotFoundError, PrinterModelNotRecognizedError +from .. import constants + + +logger = logging.getLogger(__name__) -DEVICE_RE = re.compile("Bus\s+(?P\d+)\s+Device\s+(?P\d+).+ID\s(?P\w+):(?P\w+)\s(?P.+)$", re.I) +class Printer(object): + """ Base class for all printers """ -# Identify Epson printers in usb devices -EPSON_VENDOR_ID = '04b8' + def print_image(self, image): + raise NotImplementedError() -def get_product_id(vendor_id): - df = subprocess.check_output("lsusb", shell=True) - # parse all usb devices - devices = [] - for i in df.split('\n'): - if i: - info = DEVICE_RE.match(i) - if info: - dinfo = info.groupdict() - dinfo['device'] = '/dev/bus/usb/%s/%s' % (dinfo.pop('bus'), dinfo.pop('device')) - devices.append(dinfo) + def print_image_from_file(self, file_path): + with open(file_path, "rb") as image_file: + self.print_image(image_file.read()) + + def paper_present(self): + raise NotImplemented() + + @staticmethod + def factory(): + def _factory(): + """ factory method to create different types of printers based on the output of lsusb""" + devices = get_usb_devices() + # Try finding an EPSON printer + generator = (device for device in devices if device['vendor_id'] == constants.EPSON_VENDOR_ID) + epson_printer_device = next(generator, None) + if epson_printer_device: + product_id = epson_printer_device['product_id'] + if product_id == constants.TMT20_PRODUCT_ID: + return EpsonTMT20() + if product_id == constants.TMT20II_PRODUCT_ID: + return EpsonTMT20II() + else: + raise PrinterModelNotRecognizedError(epson_printer_device) + generator = (device for device in devices if device['vendor_id'] == constants.CUSTOM_VENDOR_ID) + custom_printer_device = next(generator, None) + if custom_printer_device: + product_id = custom_printer_device['product_id'] + if product_id == constants.VKP80III_PRODUCT_ID: + return VKP80III() + else: + raise PrinterModelNotRecognizedError(custom_printer_device) + raise PrinterNotFoundError() + + try: + return _factory() + except Exception as e: + logger.error(e.message) - # Try finding an Epson printer - printer = next(device for device in devices if device['vendor_id'] == vendor_id) - if not printer: - raise Exception("No EPSON Printer detected") - return printer['product_id'] +class EpsonPrinter(Printer): -class EpsonPrinter(object): + def __init__(self, *args, **kwargs): + self.max_width = 576 + super(EpsonPrinter, self).__init__(*args, **kwargs) - def __init__(self): - vendor_id = '0x%s' % EPSON_VENDOR_ID - product_id = '0x%s' % get_product_id(EPSON_VENDOR_ID) - self.printer = epsonprinter.EpsonPrinter(int(vendor_id, 16), int(product_id, 16)) + def configure(self): self.printer.set_print_speed(2) + def image_to_raster(self, ticket): + ticket_path = join(settings.RAMDISK_ROOT, 'ticket.png') + ticket.save(ticket_path, "PNG", quality=100) + # TODO make png2pos support passing base64 file argument + speed_arg = '-s%s' % settings.PRINTER_SPEED + args = ['png2pos', '-r', speed_arg, '-aC', ticket_path] + my_env = os.environ.copy() + my_env['PNG2POS_PRINTER_MAX_WIDTH'] = str(self.max_width) + p = subprocess.Popen(args, stdout=subprocess.PIPE, env=my_env) + pos_data, err = p.communicate() + if err: + raise err + return pos_data + + def prepare_image(self, image): + im = Image.open(cStringIO.StringIO(image)) + im = resize_preserve_ratio(im, new_width=self.max_width) + if im.mode is not '1': + im = im.convert('1') + buf = cStringIO.StringIO() + im.save(buf, 'PNG') + im = buf.getvalue() + buf.close() + return im + @timeit - def print_ticket(self, ticket_data): + def print_image(self, image): + im = Image.open(cStringIO.StringIO(image)) + raster_data = self.image_to_raster(im) try: - self.printer.write(ticket_data) + self.printer.write(raster_data) self.printer.linefeed(settings.LINE_FEED_COUNT) self.printer.cut() + _, h = im.size + return h except USBError: # best guess is that we are out out of paper raise OutOfPaperError() + + def paper_present(self): + # TODO make this work + # return self.printer.paper_present() + return True + + +class EpsonTMT20(EpsonPrinter): + + def __init__(self, *args, **kwargs): + super(EpsonTMT20, self).__init__(*args, **kwargs) + product_id = '0x%s' % constants.TMT20_PRODUCT_ID + self.printer = epsonprinter.EpsonPrinter(int(product_id, 16)) + self.configure() + + +class EpsonTMT20II(EpsonPrinter): + + def __init__(self, *args, **kwargs): + super(EpsonTMT20II, self).__init__(*args, **kwargs) + product_id = '0x%s' % constants.TMT20II_PRODUCT_ID + self.printer = epsonprinter.EpsonPrinter(int(product_id, 16)) + self.configure() + + +class VKP80III(Printer): + + def __init__(self, *args, **kwargs): + super(VKP80III, self).__init__(*args, **kwargs) + self.printer = customprinters.VKP80III() + self.max_width = 640 + try: + self.configure() + except USBError: + pass + + def configure(self): + self.printer.set_print_speed(0) + + def image_to_raster(self, image): + return custom_printer_utils.image_to_raster(image) + + def prepare_image(self, image): + im = Image.open(cStringIO.StringIO(image)) + if im.mode != '1': + im = im.convert('1') + horizontal_margin = (self.max_width - im.size[0]) / 2 + border = (horizontal_margin, 20, horizontal_margin, 0) + im = add_margin(im, border) + buf = cStringIO.StringIO() + im.save(buf, 'PNG') + im = buf.getvalue() + buf.close() + return im + + @timeit + def print_image(self, image): + im = Image.open(cStringIO.StringIO(image)) + im = im.rotate(180) + raster_data = custom_printer_utils.image_to_raster(im) + xH, xL = custom_printer_utils.to_base_256(self.max_width / 8) + yH, yL = custom_printer_utils.to_base_256(im.size[1]) + try: + self.printer.print_raster_image(0, xL, xH, yL, yH, raster_data) + self.printer.present_paper(23, 1, 69, 0) + (_, h) = im.size + return h + except USBError: + raise OutOfPaperError() + + @timeit + def paper_present(self): + return self.printer.paper_present() diff --git a/figureraspbian/devices/real_time_clock.py b/figureraspbian/devices/real_time_clock.py new file mode 100644 index 0000000..4770fbd --- /dev/null +++ b/figureraspbian/devices/real_time_clock.py @@ -0,0 +1,252 @@ +import time +import operator +from datetime import datetime + +import RPi.GPIO + +from .. import settings + + +class RTC(object): + + def read_datetime(self): + raise NotImplementedError() + + def write_datetime(self, dt): + raise NotImplementedError() + + def factory(*args, **kwargs): + if settings.RTC == 'DS1302': + return RTC_DS1302() + return None + + factory = staticmethod(factory) + + + +# RTC_DS1302 - Python Hardware Programming Education Project For Raspberry Pi +# Copyright (C) 2015 Jason Birch +# +# This program 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. +# +# This program 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 this program. If not, see . + +#/****************************************************************************/ +#/* RTC_DS1302 */ +#/* ------------------------------------------------------------------------ */ +#/* V1.00 - 2015-08-26 - Jason Birch */ +#/* ------------------------------------------------------------------------ */ +#/* Class to handle controlling a Real Time Clock IC DS1302. */ +#/****************************************************************************/ + + +class RTC_DS1302: + RTC_DS1302_SCLK = settings.RTC_SCLK_PIN + RTC_DS1302_CE = settings.RTC_RST_PIN + RTC_DS1302_IO = settings.RTC_SDAT_PIN + + CLK_PERIOD = 0.00001 + + DOW = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" ] + + + def __init__(self): + # Turn off GPIO warnings. + RPi.GPIO.setwarnings(False) + # Configure Raspberry Pi GPIO interfaces. + RPi.GPIO.setmode(RPi.GPIO.BCM) + # Initiate DS1302 communication. + self.InitiateDS1302() + # Make sure write protect is turned off. + self.WriteByte(int("10001110", 2)) + self.WriteByte(int("00000000", 2)) + # Make sure trickle charge mode is turned off. + self.WriteByte(int("10010000", 2)) + self.WriteByte(int("00000000", 2)) + # End DS1302 communication. + self.EndDS1302() + + #/***********************************/ + #/* Write date and time to the RTC. */ + #/***********************************/ + def write_datetime(self, dt): + year = dt.year % 100 + month = dt.month + day = dt.day + day_of_week = dt.weekday() + hour = dt.hour + minute = dt.minute + second = dt.second + self._write_datetime(year, month, day, day_of_week, hour, minute, second) + + + #/*************************************/ + #/* Read date and time from the RTC. */ + #/*************************************/ + def read_datetime(self): + # Initiate DS1302 communication. + self.InitiateDS1302() + # Write address byte. + self.WriteByte(int("10111111", 2)) + # Read date and time data. + + Byte = self.ReadByte() + second = operator.mod(Byte, 16) + operator.div(Byte, 16) * 10 + Byte = self.ReadByte() + minute = operator.mod(Byte, 16) + operator.div(Byte, 16) * 10 + Byte = self.ReadByte() + hour = operator.mod(Byte, 16) + operator.div(Byte, 16) * 10 + Byte = self.ReadByte() + day = operator.mod(Byte, 16) + operator.div(Byte, 16) * 10 + Byte = self.ReadByte() + month = operator.mod(Byte, 16) + operator.div(Byte, 16) * 10 + Byte = self.ReadByte() + _ = (operator.mod(Byte, 16) + operator.div(Byte, 16) * 10) - 1 + Byte = self.ReadByte() + year = 2000 + operator.mod(Byte, 16) + operator.div(Byte, 16) * 10 + + dt = datetime(year, month, day, hour, minute, second) + + # End DS1302 communication. + self.EndDS1302() + return dt + + #/*************************************************/ + #/* Close Raspberry Pi GPIO use before finishing. */ + #/*************************************************/ + def CloseGPIO(self): + RPi.GPIO.cleanup() + + + #/********************************************/ + #/* Start a transaction with the DS1302 RTC. */ + #/********************************************/ + def InitiateDS1302(self): + RPi.GPIO.setup(self.RTC_DS1302_SCLK, RPi.GPIO.OUT, initial=0) + RPi.GPIO.setup(self.RTC_DS1302_CE, RPi.GPIO.OUT, initial=0) + RPi.GPIO.setup(self.RTC_DS1302_IO, RPi.GPIO.OUT, initial=0) + RPi.GPIO.output(self.RTC_DS1302_SCLK, 0) + RPi.GPIO.output(self.RTC_DS1302_IO, 0) + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_CE, 1) + + + #/***********************************************/ + #/* Complete a transaction with the DS1302 RTC. */ + #/***********************************************/ + def EndDS1302(self): + RPi.GPIO.setup(self.RTC_DS1302_SCLK, RPi.GPIO.OUT, initial=0) + RPi.GPIO.setup(self.RTC_DS1302_CE, RPi.GPIO.OUT, initial=0) + RPi.GPIO.setup(self.RTC_DS1302_IO, RPi.GPIO.OUT, initial=0) + RPi.GPIO.output(self.RTC_DS1302_SCLK, 0) + RPi.GPIO.output(self.RTC_DS1302_IO, 0) + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_CE, 0) + + + #/*******************************************/ + #/* Write a byte of data to the DS1302 RTC. */ + #/*******************************************/ + def WriteByte(self, Byte): + for Count in range(8): + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_SCLK, 0) + + Bit = operator.mod(Byte, 2) + Byte = operator.div(Byte, 2) + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_IO, Bit) + + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_SCLK, 1) + + + #/******************************************/ + #/* Read a byte of data to the DS1302 RTC. */ + #/******************************************/ + def ReadByte(self): + RPi.GPIO.setup(self.RTC_DS1302_IO, RPi.GPIO.IN, pull_up_down=RPi.GPIO.PUD_DOWN) + + Byte = 0 + for Count in range(8): + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_SCLK, 1) + + time.sleep(self.CLK_PERIOD) + RPi.GPIO.output(self.RTC_DS1302_SCLK, 0) + + time.sleep(self.CLK_PERIOD) + Bit = RPi.GPIO.input(self.RTC_DS1302_IO) + Byte |= ((2 ** Count) * Bit) + + return Byte + + + #/***********************************/ + #/* Write a message to the RTC RAM. */ + #/***********************************/ + def WriteRAM(self, Data): + # Initiate DS1302 communication. + self.InitiateDS1302() + # Write address byte. + self.WriteByte(int("11111110", 2)) + # Write data bytes. + for Count in range(len(Data)): + self.WriteByte(ord(Data[Count:Count + 1])) + for Count in range(31 - len(Data)): + self.WriteByte(ord(" ")) + # End DS1302 communication. + self.EndDS1302() + + + #/**********************************/ + #/* Read message from the RTC RAM. */ + #/**********************************/ + def ReadRAM(self): + # Initiate DS1302 communication. + self.InitiateDS1302() + # Write address byte. + self.WriteByte(int("11111111", 2)) + # Read data bytes. + Data = "" + for Count in range(31): + Byte = self.ReadByte() + Data += chr(Byte) + # End DS1302 communication. + self.EndDS1302() + return Data + + def _write_datetime(self, Year, Month, Day, DayOfWeek, Hour, Minute, Second): + # Initiate DS1302 communication. + self.InitiateDS1302() + # Write address byte. + self.WriteByte(int("10111110", 2)) + # Write seconds data. + self.WriteByte(operator.mod(Second, 10) | operator.div(Second, 10) * 16) + # Write minute data. + self.WriteByte(operator.mod(Minute, 10) | operator.div(Minute, 10) * 16) + # Write hour data. + self.WriteByte(operator.mod(Hour, 10) | operator.div(Hour, 10) * 16) + # Write day data. + self.WriteByte(operator.mod(Day, 10) | operator.div(Day, 10) * 16) + # Write month data. + self.WriteByte(operator.mod(Month, 10) | operator.div(Month, 10) * 16) + # Write day of week data. + self.WriteByte(operator.mod(DayOfWeek, 10) | operator.div(DayOfWeek, 10) * 16) + # Write year of week data. + self.WriteByte(operator.mod(Year, 10) | operator.div(Year, 10) * 16) + # Make sure write protect is turned off. + self.WriteByte(int("00000000", 2)) + # Make sure trickle charge mode is turned off. + self.WriteByte(int("00000000", 2)) + # End DS1302 communication. + self.EndDS1302() diff --git a/figureraspbian/devices/remote_release_connector.py b/figureraspbian/devices/remote_release_connector.py new file mode 100644 index 0000000..e092926 --- /dev/null +++ b/figureraspbian/devices/remote_release_connector.py @@ -0,0 +1,51 @@ + +import time + +from pifacedigitalio import PiFaceDigital +import gpiozero + +from .. import settings +from ..exceptions import InvalidIOInterfaceError + + +class RemoteReleaseConnector(object): + + def __init__(self, pin): + self.pin = pin + + def trigger(self): + raise NotImplementedError() + + def factory(*args, **kwargs): + if settings.IO_INTERFACE == 'PIFACE': + return PiFaceRemoteReleaseConnector(*args, **kwargs) + elif settings.IO_INTERFACE == 'GPIOZERO': + return GPIOZeroRemoteReleaseConnector(*args, **kwargs) + else: + raise InvalidIOInterfaceError() + + factory = staticmethod(factory) + + +class PiFaceRemoteReleaseConnector(RemoteReleaseConnector): + + def __init__(self, *args, **kwargs): + super(PiFaceRemoteReleaseConnector, self).__init__(*args, **kwargs) + self.pifacedigital = PiFaceDigital() + + def trigger(self): + self.pifacedigital.relays[self.pin].turn_on() + time.sleep(0.1) + self.pifacedigital.relays[self.pin].turn_off() + + +class GPIOZeroRemoteReleaseConnector(RemoteReleaseConnector): + + def __init__(self, *args, **kwargs): + super(GPIOZeroRemoteReleaseConnector, self).__init__(*args, **kwargs) + self.device = gpiozero.OutputDevice(self.pin) + + def trigger(self): + self.device.on() + time.sleep(0.1) + self.device.off() diff --git a/figureraspbian/exceptions.py b/figureraspbian/exceptions.py index 1b446e1..6c7bcfb 100644 --- a/figureraspbian/exceptions.py +++ b/figureraspbian/exceptions.py @@ -4,9 +4,39 @@ class FigureError(Exception): """ Base class for all exceptions in figure raspbian""" +class PrinterNotFoundError(FigureError): + + def __init__(self): + super(PrinterNotFoundError, self).__init__("Printer not found in usb devices list") + + +class PrinterModelNotRecognizedError(FigureError): + """ Error raised when no compatible printer was found in the list of usb devices """ + + def __init__(self, device): + msg = "Unknown model %s (product id %s)" % (device['tag'], device['product_id']) + super(PrinterModelNotRecognizedError, self).__init__(msg) + + +class PhotoboothNotReady(FigureError): + """ Error raised when a trigger occur on a non properly initialized photobooth """ + + class DevicesBusy(FigureError): """ Error raised when trying to access devices that are locked by another thread """ class OutOfPaperError(FigureError): """ Error raised when the photobooth runs out of paper """ + + +class InvalidIOInterfaceError(FigureError): + """ Error raised when the IO interface specified is not valid """ + + +class TimeoutWaitingForFileAdded(FigureError): + """ Error raised when no picture is received from the camera after a certain amount of time """ + + +class UnknownFilterException(FigureError): + """ Error raised when an invalid PIL image filter name was provided """ \ No newline at end of file diff --git a/figureraspbian/models.py b/figureraspbian/models.py new file mode 100644 index 0000000..fde8cc9 --- /dev/null +++ b/figureraspbian/models.py @@ -0,0 +1,350 @@ +# -*- coding: utf8 -*- + +from peewee import CharField, TextField, ForeignKeyField, FloatField, IntegerField, BooleanField, DateTimeField + +from os.path import basename + +import settings +import utils +from db import db + + +class Place(db.Model): + + name = CharField() + tz = CharField(default='Europe/Paris') + modified = CharField() + + @classmethod + def update_or_create(cls, place): + try: + p = cls.get(id=place['id']) + p.name = place.get('name') + p.tz = place.get('tz') + p.modified = place.get('modified') + p.save() + except cls.DoesNotExist: + p = cls.create(id=place['id'], name=place.get('name'), tz=place.get('tz'), modified=place.get('modified')) + return p + + +class Event(db.Model): + + name = CharField() + modified = CharField() + + @classmethod + def update_or_create(cls, event): + try: + e = cls.get(id=event['id']) + e.name = event.get('name') + e.modified = event.get('modified') + e.save() + except cls.DoesNotExist: + e = cls.create(id=event['id'], name=event.get('name'), modified=event.get('modified')) + return e + + +class TicketTemplate(db.Model): + + html = TextField() + title = TextField(null=True) + description = TextField(null=True) + modified = CharField() + + def serialize(self): + data = self.__dict__['_data'] + data['images'] = [image.serialize() for image in self.images] + data['text_variables'] = [text_variable.serialize() for text_variable in self.text_variables] + data['image_variables'] = [image_variable.serialize() for image_variable in self.image_variables] + return data + + @classmethod + def update_or_create(cls, ticket_template): + + try: + tt = cls.get(TicketTemplate.id == ticket_template['id']) + tt.html = ticket_template['html'] + tt.title = ticket_template['title'] + tt.description = ticket_template['description'] + tt.modified = ticket_template['modified'] + tt.save() + except cls.DoesNotExist: + tt = cls.create(**ticket_template) + + for text_variable in ticket_template['text_variables']: + TextVariable.update_or_create(text_variable, ticket_template=tt) + + for image_variable in ticket_template['image_variables']: + ImageVariable.update_or_create(image_variable, ticket_template=tt) + + for image in ticket_template['images']: + Image.update_or_create(image, ticket_template=tt) + + return tt + + +class TextVariable(db.Model): + + name = CharField() + ticket_template = ForeignKeyField(TicketTemplate, null=True, related_name='text_variables') + mode = CharField() + + def serialize(self): + data = { + 'id': self.id, + 'name': self.name, + 'mode': self.mode, + 'items': [text.serialize() for text in self.items] + } + return data + + @classmethod + def update_or_create(cls, text_variable, ticket_template=None): + try: + tv = cls.get(TextVariable.id == text_variable['id']) + if tv.name != text_variable.get('name') or tv.mode != text_variable.get('mode'): + tv.name = text_variable.get('name') + tv.mode = text_variable.get('mode') + tv.save() + text_ids = [item['id'] for item in text_variable['items']] + query = Text.select().where(~(Text.id << text_ids)).join(TextVariable).where(TextVariable.id == text_variable['id']) + for text in query: + text.delete_instance() + except cls.DoesNotExist: + tv = cls.create(ticket_template=ticket_template, **text_variable) + for text in text_variable['items']: + Text.update_or_create(text, tv) + return tv + + +class Text(db.Model): + + value = TextField() + variable = ForeignKeyField(TextVariable, related_name='items', null=True) + + def serialize(self): + return {'id': self.id, 'text': self.value} + + @classmethod + def update_or_create(cls, text, variable=None): + try: + txt = Text.get(cls.id == text['id']) + if txt.value != text['text']: + txt.value = text['text'] + txt.save() + return txt + except cls.DoesNotExist: + return Text.create(id=text['id'], value=text['text'], variable=variable) + + +class ImageVariable(db.Model): + + name = CharField() + ticket_template = ForeignKeyField(TicketTemplate, null=True, related_name='image_variables') + mode = CharField() + + def serialize(self): + data = { + 'id': self.id, + 'name': self.name, + 'mode': self.mode, + 'items': [image.serialize() for image in self.items] + } + return data + + @classmethod + def update_or_create(cls, image_variable, ticket_template=None): + try: + iv = cls.get(ImageVariable.id == image_variable['id']) + if iv.name != image_variable.get('name') or iv.mode != image_variable.get('mode'): + iv.name = image_variable.get('name') + iv.mode = image_variable.get('mode') + iv.save() + image_ids = [item['id'] for item in image_variable['items']] + query = Image.select().where(~(Image.id << image_ids)).join(ImageVariable).where(ImageVariable.id == image_variable['id']) + for image in query: + image.delete_instance() + except cls.DoesNotExist: + iv = cls.create(ticket_template=ticket_template, **image_variable) + for image in image_variable['items']: + Image.update_or_create(image, variable=iv) + return iv + + +class Image(db.Model): + + path = TextField() + variable = ForeignKeyField(ImageVariable, related_name='items', null=True) + ticket_template = ForeignKeyField(TicketTemplate, related_name='images', null=True) + + def serialize(self): + return {'id': self.id, 'name': basename(self.path)} + + @classmethod + def update_or_create(cls, image, variable=None, ticket_template=None): + try: + img = cls.get(Image.id == image['id']) + if basename(img.path) != image['name']: + path = utils.download(image['image'], settings.IMAGE_ROOT) + img.path = path + img.save() + return img + except cls.DoesNotExist: + path = utils.download(image['image'], settings.IMAGE_ROOT) + return cls.create(id=image['id'], path=path, variable=variable, ticket_template=ticket_template) + + +class Photobooth(db.Model): + + uuid = CharField(unique=True) + serial_number = CharField(null=True) + place = ForeignKeyField(Place, null=True) + event = ForeignKeyField(Event, null=True) + ticket_template = ForeignKeyField(TicketTemplate, null=True) + paper_level = FloatField(default=100.0) + counter = IntegerField(default=0) + + def update_from_api_data(self, photobooth): + + update_dict = {} + + if self.id != photobooth['id']: + update_dict['id'] = photobooth['id'] + + serial_number = photobooth.get('serial_number') + if self.serial_number != serial_number: + update_dict['serial_number'] = serial_number + + # check if we need to update the place + place = photobooth.get('place') + + if place and not self.place: + p = Place.update_or_create(place) + update_dict['place'] = p + + elif not place and self.place: + self.place.delete_instance() + update_dict['place'] = None + + elif place and self.place and place.get('id') != self.place.id: + self.place.delete_instance() + p = Place.update_or_create(place) + update_dict['place'] = p + + elif place and self.place and place.get('modified') > self.place.modified: + self.place.name = place.get('name') + self.place.tz = place.get('tz') + self.place.modified = place.get('modified') + self.place.save() + + # check if we need to update the event + event = photobooth.get('event') + if event and not self.event: + e = Event.update_or_create(event) + update_dict['event'] = e + + elif not event and self.event: + self.event.delete_instance() + update_dict['event'] = None + + elif event and self.event and event.get('id') != self.event.id: + self.event.delete_instance() + e = Event.update_or_create(event) + update_dict['event'] = e + + elif event and self.event and event.get('modified') > self.event.modified: + self.event.name = event.get('name') + self.event.modified = event.get('modified') + self.event.save() + + # check if we need to update the ticket template + ticket_template = photobooth.get('ticket_template') + + if ticket_template and not self.ticket_template: + t = TicketTemplate.update_or_create(ticket_template) + update_dict['ticket_template'] = t + + elif not ticket_template and self.ticket_template: + self.ticket_template.delete_instance() + update_dict['ticket_template'] = None + + elif ticket_template and self.ticket_template and ticket_template.get('id') != self.ticket_template.id: + self.ticket_template.delete_instance() + t = TicketTemplate.update_or_create(ticket_template) + update_dict['ticket_template'] = t + + elif ticket_template and self.ticket_template and ticket_template.get('modified') > self.ticket_template.modified: + TicketTemplate.update_or_create(ticket_template) + + if update_dict: + q = Photobooth.update(**update_dict).where(Photobooth.uuid == settings.RESIN_UUID) + return q.execute() + + return 0 + + +class Code(db.Model): + + value = CharField() + + @staticmethod + def pop(): + code = Code.select().limit(1)[0] + value = code.value + code.delete_instance() + return value + + @staticmethod + def less_than_1000_left(): + return Code.select().count() < 1000 + + @staticmethod + def bulk_insert(codes): + with db.database.atomic(): + for code in codes: + Code.create(value=code) + + +class Portrait(db.Model): + + code = CharField() + taken = DateTimeField() + place_id = CharField(null=True) + event_id = CharField(null=True) + photobooth_id = CharField() + ticket = CharField() + picture = CharField() + uploaded = BooleanField(default=False) + + @staticmethod + def not_uploaded(): + return Portrait.select().where(~ Portrait.uploaded) + + @staticmethod + def first_not_uploaded(): + """ return first portrait to be uploaded """ + try: + return Portrait.not_uploaded().get() + except Portrait.DoesNotExist: + return None + + @staticmethod + def not_uploaded_count(): + """ Retrieves the number of portraits to be uploaded """ + return Portrait.not_uploaded().count() + + +def get_all_models(): + return [ + Place, + Event, + Text, + Image, + TextVariable, + ImageVariable, + TicketTemplate, + Portrait, + Photobooth, + Code + ] diff --git a/figureraspbian/phantomjs.py b/figureraspbian/phantomjs.py deleted file mode 100644 index 73cc4f4..0000000 --- a/figureraspbian/phantomjs.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf8 -*- - -import subprocess - -from figureraspbian import settings -from figureraspbian.utils import timeit - - -@timeit -def get_screenshot(html): - args = [settings.PHANTOMJS_PATH, './figureraspbian/ticket.js', html] - data = subprocess.check_output(args) - return data - - - diff --git a/figureraspbian/photobooth.py b/figureraspbian/photobooth.py index b733afa..56e5bc7 100644 --- a/figureraspbian/photobooth.py +++ b/figureraspbian/photobooth.py @@ -1,431 +1,213 @@ # -*- coding: utf8 -*- -from threading import Thread, RLock + from datetime import datetime import pytz import cStringIO -import base64 import logging +from threading import Thread import time -from os.path import join -import errno +from os import path from ticketrenderer import TicketRenderer -import figure -from gpiozero import PingServer - -from figureraspbian import settings -from figureraspbian.devices.camera import Camera -from figureraspbian.devices.printer import EpsonPrinter -from figureraspbian.devices.door_lock import PiFaceDigitalDoorLock -from figureraspbian.utils import get_base64_picture_thumbnail, get_pure_black_and_white_ticket, \ - png2pos, get_file_name, download, write_file, get_mac_addresses, render_jinja_template -from figureraspbian.decorators import execute_if_not_busy -from figureraspbian.phantomjs import get_screenshot -from figureraspbian import db -from figureraspbian.threads import Interval -from figureraspbian.exceptions import DevicesBusy, OutOfPaperError - -logger = logging.getLogger(__name__) - -figure.api_base = settings.API_HOST -figure.token = settings.TOKEN - -camera = None -printer = None -button = None -door_lock = None - -lock = RLock() - - -def initialize(): - """ - Initialize devices, data and stylesheets - """ - logging.basicConfig(format=settings.LOG_FORMAT, datefmt='%Y.%m.%d %H:%M:%S', level='INFO') - # Disable logs for request library - logging.getLogger("requests").setLevel(logging.WARNING) - - try: - initialize_devices() - except Exception as e: - logger.exception(e) - - if is_online(): - try: - download_ticket_stylesheet() - except Exception as e: - logger.exception(e) - - try: - download_booting_ticket_template() - except Exception as e: - logger.exception(e) - - # update photobooth - try: - update() - except Exception as e: - logger.exception(e) - - # grab new codes if necessary - try: - claim_new_codes() - except Exception as e: - logger.exception(e) +from PIL import Image - # update mac addresses - update_mac_addresses_async() +from models import Code, Photobooth as PhotoboothModel +import settings +import utils +from decorators import execute_if_not_busy +from exceptions import OutOfPaperError, DevicesBusy, PhotoboothNotReady +import request +from devices.camera import Camera +from devices.printer import Printer +from devices.door_lock import DoorLock +from threads import rlock +import webkit2png -def set_intervals(): - """ - Start tasks that are run in the background at regular intervals - """ - - intervals = [ - Interval(update, settings.UPDATE_POLL_INTERVAL), - Interval(upload_portraits, settings.UPLOAD_PORTRAITS_INTERVAL) - ] - - for interval in intervals: - interval.start() - - return intervals - - -def initialize_devices(): - global camera, printer, button, door_lock - camera = Camera() - printer = EpsonPrinter() - door_lock = PiFaceDigitalDoorLock() - - -def download_ticket_stylesheet(): - download(settings.TICKET_CSS_URL, settings.STATIC_ROOT, force=True) - - -def download_booting_ticket_template(): - download(settings.LOGO_FIGURE_URL, settings.STATIC_ROOT) - download(settings.BOOTING_TICKET_TEMPLATE_URL, settings.STATIC_ROOT, force=True) - - -def door_open(): - door_lock.open() - time.sleep(settings.DOOR_OPENING_TIME) - door_lock.close() - - -def trigger(): - try: - _trigger() - except DevicesBusy: - pass - -@execute_if_not_busy(lock) -def _trigger(): - """ - Execute a sequence of actions on devices after a trigger occurs - Eg: - - take a photo - - render a ticket - - print a ticket - - upload files - - etc - :return: - """ - - picture, exif_bytes = camera.capture() - return render_print_and_upload(picture, exif_bytes) - -@execute_if_not_busy(lock) -def render_print_and_upload(picture, exif_bytes): - """ - The body of this function is not included in the _trigger function above because we want to print tickets - with user provided picture. See figureraspbian.api.test_template - """ - photobooth = db.get_photobooth() - - ticket_renderer = TicketRenderer( - photobooth.ticket_template.serialize(), settings.MEDIA_URL, settings.LOCAL_TICKET_CSS_URL) - - code = db.get_code() - tz = photobooth.place.tz if photobooth.place else settings.DEFAULT_TIMEZONE - date = datetime.now(pytz.timezone(tz)) - base64_picture_thumb = get_base64_picture_thumbnail(picture) - - rendered = ticket_renderer.render( - picture="data:image/jpeg;base64,%s" % base64_picture_thumb, - code=code, - date=date, - counter=photobooth.counter - ) - - del base64_picture_thumb - - ticket_base64 = get_screenshot(rendered) - ticket_io = base64.b64decode(ticket_base64) - ticket_path, ticket_length = get_pure_black_and_white_ticket(ticket_io) - - pos_data = png2pos(ticket_path) - - try: - printer.print_ticket(pos_data) - update_paper_level(ticket_length) - except OutOfPaperError: - update_paper_level(0) - buf = cStringIO.StringIO() - if exif_bytes: - picture.save(buf, "JPEG", exif=exif_bytes) - else: - picture.save(buf, "JPEG") - picture_io = buf.getvalue() - buf.close() - - filename = get_file_name(code) - - portrait = { - 'picture': picture_io, - 'ticket': ticket_io, - 'taken': date, - 'place': photobooth.place.id if photobooth.place else None, - 'event': photobooth.event.id if photobooth.event else None, - 'photobooth': photobooth.id, - 'code': code, - 'filename': filename - } - - db.increment_counter() - claim_new_codes_async() - upload_portrait_async(portrait) - - return ticket_path - -@execute_if_not_busy(lock) -def print_booting_ticket(): - if printer: - booting_template_path = join(settings.STATIC_ROOT, 'booting.html') - _photobooth = db.get_photobooth() - tz = _photobooth.place.tz if _photobooth.place else settings.DEFAULT_TIMEZONE - rendered = render_jinja_template( - booting_template_path, - css_url=settings.LOCAL_TICKET_CSS_URL, - logo_url=settings.LOCAL_LOGO_FIGURE_URL, - date=datetime.now(pytz.timezone(tz)).strftime('%d/%m/%Y %H:%M'), - serial_number=_photobooth.serial_number, - place=_photobooth.place.name if _photobooth.place else None, - event=_photobooth.event.name if _photobooth.event else None, - is_online=is_online() - ) - ticket_base64 = get_screenshot(rendered) - ticket_io = base64.b64decode(ticket_base64) - ticket_path, ticket_length = get_pure_black_and_white_ticket(ticket_io) - pos_data = png2pos(ticket_path) - printer.print_ticket(pos_data) - - -def trigger_async(): - thr = Thread(target=trigger, args=(), kwargs={}) - thr.start() - return thr - - -def update(): - """ - This will update the data in case it has been changed in the API - """ - logger.info("Updating data...") - - current = db.get_photobooth() - - next = figure.Photobooth.get(settings.RESIN_UUID) - - if current.id != next['id']: - db.update_photobooth(id=next['id'], serial_number=next.get('serial_number')) - - # check if we need to update the place - place = next.get('place') - - if place and not current.place: - p = db.create_place(place) - db.update_photobooth(place=p) - - elif not place and current.place: - db.delete(current.place) - db.update_photobooth(place=None) - - elif place and current.place and place.get('id') != current.place.id: - db.delete(current.place) - p = db.create_place(place) - db.update_photobooth(place=p) - - elif place and current.place and place.get('modified') > current.place.modified: - db.update_place(current.place.id, name=place.get('name'), tz=place.get('tz'), modified=place.get('modified')) - - # check if we need to update the event - event = next.get('event') - if event and not current.event: - e = db.create_event(event) - db.update_photobooth(event=e) - - elif not event and current.event: - db.delete(current.event) - db.update_photobooth(event=None) - - elif event and current.event and event.get('id') != current.event.id: - db.delete(current.event) - e = db.create_event(event) - db.update_photobooth(event=e) - - elif event and current.event and event.get('modified') > current.event.modified: - db.update_event(current.event.id, name=event.get('name'), modified=event.get('modified')) - - # check if we need to update the ticket template - ticket_template = next.get('ticket_template') - - if ticket_template and not current.ticket_template: - t = db.update_or_create_ticket_template(ticket_template) - db.update_photobooth(ticket_template=t) - - elif not ticket_template and current.ticket_template: - db.delete(current.ticket_template) - db.update_photobooth(ticket_template=None) - - elif ticket_template and current.ticket_template and ticket_template.get('id') != current.ticket_template.id: - db.delete(current.ticket_template) - t = db.update_or_create_ticket_template(ticket_template) - db.update_photobooth(ticket_template=t) - - elif ticket_template and current.ticket_template and ticket_template.get('modified') > current.ticket_template.modified: - db.update_or_create_ticket_template(ticket_template) - - logger.info("Data updated !") - - -def upload_portrait(portrait): - """ Upload a portrait to Figure API or save it to local file system if an error occurs""" - - files = { - 'picture_color': (portrait['filename'], portrait['picture']), - 'ticket': (portrait['filename'], portrait['ticket']) - } - - data = {key: portrait[key] for key in ['code', 'taken', 'place', 'event', 'photobooth']} - - try: - logger.info('Uploading portrait %s' % portrait['code']) - figure.Portrait.create(data=data, files=files) - logger.info('Portrait %s uploaded !' % portrait['code']) - except Exception as e: - logger.error(e) - # Couldn't upload the portrait, save picture and ticket - # to filesystem and add the portrait to local db for scheduled upload - picture_path = join(settings.PICTURE_ROOT, portrait['filename']) - write_file(portrait['picture'], picture_path) - portrait['picture'] = picture_path - - ticket_path = join(settings.TICKET_ROOT, portrait['filename']) - write_file(portrait['ticket'], ticket_path) - portrait['ticket'] = ticket_path - - portrait.pop('filename') - - db.create_portrait(portrait) - - -def upload_portrait_async(portrait): - thr = Thread(target=upload_portrait, args=(portrait,), kwargs={}) - thr.start() - - -def upload_portraits(): +logger = logging.getLogger(__name__) - number_of_portraits = db.get_number_of_portraits_to_be_uploaded() - if number_of_portraits == 0: - logger.info('No portrait to be uploaded by worker') - else: - logger.info('There are %s to be uploaded...' % number_of_portraits) - while True: - portrait = db.get_portrait_to_be_uploaded() - if portrait: - logger.info('Uploading portrait %s...' % portrait.code) +class Photobooth(object): + + def __init__(self): + # data + self.photobooth = PhotoboothModel.get() + self.context = None + self.update_dict = {} + # devices + self.camera = self.printer = self.door_lock = None + self.ready = False + self.initialize_devices() + if self.camera and self.printer: + self.ready = True + + def initialize_devices(self): + self.camera = Camera.factory() + if self.camera: + self.camera.clear_space() + self.printer = Printer.factory() + self.door_lock = DoorLock.factory(settings.DOOR_LOCK_PIN) + + def trigger(self): + if self.ready: try: - files = {'picture_color': open(portrait.picture, 'rb'), 'ticket': open(portrait.ticket, 'rb')} - data = { - 'code': portrait.code, - 'taken': portrait.taken, - 'place': portrait.place_id, - 'event': portrait.event_id, - 'photobooth': portrait.photobooth_id, - } - figure.Portrait.create(data=data, files=files) - db.update_portrait(portrait.id, uploaded=True) - logger.info('Portrait %s uploaded !' % portrait.code) - except figure.BadRequestError as e: - # Duplicate code or files empty - logger.exception(e) - db.delete(portrait) - except IOError as e: - logger.exception(e) - if e.errno == errno.ENOENT: - # snapshot or ticket file may be corrupted, proceed with remaining tickets - db.delete(portrait) - else: - break - except Exception as e: - logger.exception(e) - break + return self._trigger() + except DevicesBusy: + pass else: - break - - -def update_paper_level(pixels): - paper_level = db.update_paper_level(pixels) - logger.info('Paper level is now %s percent' % paper_level) - update_api_paper_level_async(paper_level) + raise PhotoboothNotReady() + + @execute_if_not_busy(rlock) + def _trigger(self): + self.photobooth = PhotoboothModel.get() + if self.photobooth.paper_level == 0: + # check if someone has refilled the paper + paper_present = self.printer.paper_present() + if not paper_present: + return + picture = self.camera.capture() + return self.render_print_and_upload(picture) + + @execute_if_not_busy(rlock) + def render_print_and_upload(self, picture): + self.set_context() + html = self.render_ticket(picture) + ticket = webkit2png.get_screenshot(html) + try: + ticket_length = self.print_image(ticket) + self.update_dict['paper_level'] = utils.new_paper_level(self.paper_level, ticket_length) + except OutOfPaperError: + logger.info("The printer is out of paper") + self.update_dict['paper_level'] = 0 + filename = utils.get_file_name(self.context['code']) + + portrait = { + 'picture': picture, + 'ticket': ticket, + 'taken': self.context['date'], + 'place': self.place.id if self.place else None, + 'event': self.event.id if self.event else None, + 'photobooth': self.id, + 'code': self.context['code'], + 'filename': filename + } + + q = PhotoboothModel.update(counter=PhotoboothModel.counter + 1, **self.update_dict) + q = q.where(PhotoboothModel.uuid == settings.RESIN_UUID) + q.execute() + self.photobooth = PhotoboothModel.get() + + request.upload_portrait_async(portrait) + request.update_paper_level_async(self.paper_level) + + return ticket + + def trigger_async(self): + thr = Thread(target=self.trigger, args=(), kwargs={}) + thr.start() + return thr + + def set_context(self): + """ returns the context used to generate a ticket from a ticket template """ + code = Code.pop() + tz = self.place.tz if self.place else settings.DEFAULT_TIMEZONE + date = datetime.now(pytz.timezone(tz)) + counter = self.counter + self.context = {'code': code, 'date': date, 'counter': counter} + + def render_ticket(self, picture): + ticket_renderer = TicketRenderer( + self.ticket_template.serialize(), + settings.MEDIA_URL, + settings.LOCAL_TICKET_CSS_URL) + # resize picture + w = h = settings.TICKET_TEMPLATE_PICTURE_SIZE + pil_picture = Image.open(cStringIO.StringIO(picture)) + resized = pil_picture.resize((w, h)) + resized.format = pil_picture.format + data_url = utils.get_data_url(resized) + html = ticket_renderer.render(data_url, **self.context) + return html + + def unlock_door(self): + self.door_lock.open() + time.sleep(settings.DOOR_OPENING_TIME) + self.door_lock.close() + + @execute_if_not_busy(rlock) + def print_booting_ticket(self): + if self.ready: + booting_template_path = path.join(settings.STATIC_ROOT, 'booting.html') + tz = self.place.tz if self.place else settings.DEFAULT_TIMEZONE + rendered = utils.render_jinja_template( + booting_template_path, + css_url=settings.LOCAL_TICKET_CSS_URL, + logo_url=settings.LOCAL_LOGO_FIGURE_URL, + date=datetime.now(pytz.timezone(tz)).strftime('%d/%m/%Y %H:%M'), + serial_number=self.serial_number, + place=self.place.name if self.place else None, + event=self.event.name if self.event else None, + is_online=request.is_online() + ) + ticket = webkit2png.get_screenshot(rendered) + self.print_image(ticket) + + @execute_if_not_busy(rlock) + def focus_camera(self, steps=None): + if steps: + self.camera.focus(steps) + else: + self.camera.focus() + @execute_if_not_busy(rlock) + def print_image(self, image): + image = self.printer.prepare_image(image) + return self.printer.print_image(image) -def update_api_paper_level(paper_level): - figure.Photobooth.edit( - settings.RESIN_UUID, data={'paper_level': paper_level}) - logger.info('API updated with new paper level!') + @property + def id(self): + return self.photobooth.id + @property + def serial_number(self): + return self.photobooth.serial_number -def update_api_paper_level_async(paper_level): - thr = Thread(target=update_api_paper_level, args=(paper_level,), kwargs={}) - thr.start() + @property + def place(self): + return self.photobooth.place + @property + def event(self): + return self.photobooth.event -def update_mac_addresses(): - mac_addresses = get_mac_addresses() - figure.Photobooth.edit( - settings.RESIN_UUID, data={'mac_addresses': mac_addresses}) + @property + def ticket_template(self): + return self.photobooth.ticket_template + @property + def counter(self): + return self.photobooth.counter -def update_mac_addresses_async(): - thr = Thread(target=update_mac_addresses, args=(), kwargs={}) - thr.start() + @counter.setter + def counter(self, value): + self.photobooth.counter = value + @property + def paper_level(self): + return self.photobooth.paper_level -def claim_new_codes(): - if db.should_claim_code(): - logger.info('We are running out of codes, fetching new ones from API...') - new_codes = figure.Code.claim(data={'number': 10000}) - db.bulk_insert_codes(new_codes) - logger.info('New codes fetched and saved !') + @paper_level.setter + def paper_level(self, value): + self.photobooth.paper_level = value -def claim_new_codes_async(): - thr = Thread(target=claim_new_codes, args=(), kwargs={}) - thr.start() +_photobooth = None -def is_online(): - # check if the device is online - ping_host = '8.8.8.8' - server = PingServer(ping_host) - b = server.is_active - server.close() - return b +def get_photobooth(): + """ Instantiate photobooth lazily """ + global _photobooth + if not _photobooth: + _photobooth = Photobooth() + return _photobooth \ No newline at end of file diff --git a/figureraspbian/request.py b/figureraspbian/request.py new file mode 100644 index 0000000..ce01599 --- /dev/null +++ b/figureraspbian/request.py @@ -0,0 +1,157 @@ +# -*- coding: utf8 -*- + +import logging +from os import path +from threading import Thread +import errno + +import figure +from gpiozero import PingServer + +import settings +from models import Photobooth, Portrait, Code +import utils + + +figure.api_base = settings.API_HOST +figure.token = settings.TOKEN + + +logger = logging.getLogger(__name__) + + +def download_ticket_stylesheet(): + utils.download(settings.TICKET_CSS_URL, settings.STATIC_ROOT, force=True) + + +def download_booting_ticket_template(): + utils.download(settings.LOGO_FIGURE_URL, settings.STATIC_ROOT) + utils.download(settings.BOOTING_TICKET_TEMPLATE_URL, settings.STATIC_ROOT, force=True) + + +def update(): + """ This will update the data in case it has been changed in the API """ + photobooth = Photobooth.get() + updated = figure.Photobooth.get(settings.RESIN_UUID) + return photobooth.update_from_api_data(updated) + + +def upload_portrait(portrait): + """ Upload a portrait to Figure API or save it to local file system if an error occurs""" + + files = { + 'picture_color': (portrait['filename'], portrait['picture']), + 'ticket': (portrait['filename'], portrait['ticket']) + } + + data = {key: portrait[key] for key in ['code', 'taken', 'place', 'event', 'photobooth']} + + try: + logger.info('Uploading portrait %s' % portrait['code']) + figure.Portrait.create(data=data, files=files) + logger.info('Portrait %s uploaded !' % portrait['code']) + except Exception as e: + logger.error(e) + # Couldn't upload the portrait, save picture and ticket + # to filesystem and add the portrait to local db for scheduled upload + picture_path = path.join(settings.PICTURE_ROOT, portrait['filename']) + utils.write_file(portrait['picture'], picture_path) + + ticket_path = path.join(settings.TICKET_ROOT, portrait['filename']) + utils.write_file(portrait['ticket'], ticket_path) + + portrait['picture'] = picture_path + portrait['ticket'] = ticket_path + portrait.pop('filename') + + portrait['place_id'] = portrait.pop('place') + portrait['event_id'] = portrait.pop('event') + portrait['photobooth_id'] = portrait.pop('photobooth') + + Portrait.create(**portrait) + + +def upload_portrait_async(portrait): + thr = Thread(target=upload_portrait, args=(portrait,), kwargs={}) + thr.start() + + +def upload_portraits(): + + not_uploaded_count = Portrait.not_uploaded_count() + + if not_uploaded_count > 0: + + logger.info('There are %s to be uploaded...' % not_uploaded_count) + + while True: + portrait = Portrait.first_not_uploaded() + if portrait: + logger.info('Uploading portrait %s...' % portrait.code) + try: + files = {'picture_color': open(portrait.picture, 'rb'), 'ticket': open(portrait.ticket, 'rb')} + data = { + 'code': portrait.code, + 'taken': portrait.taken, + 'place': portrait.place_id, + 'event': portrait.event_id, + 'photobooth': portrait.photobooth_id, + } + figure.Portrait.create(data=data, files=files) + portrait.uploaded = True + portrait.save() + logger.info('Portrait %s uploaded !' % portrait.code) + except figure.BadRequestError as e: + # Duplicate code or files empty + logger.exception(e) + portrait.delete_instance() + except IOError as e: + logger.exception(e) + if e.errno == errno.ENOENT: + # snapshot or ticket file may be corrupted, proceed with remaining tickets + portrait.delete_instance() + else: + break + except Exception as e: + logger.exception(e) + break + else: + break + + +def update_paper_level(paper_level): + figure.Photobooth.edit( + settings.RESIN_UUID, data={'paper_level': paper_level}) + logger.info('API updated with new paper level!') + + +def update_paper_level_async(paper_level): + thr = Thread(target=update_paper_level, args=(paper_level,), kwargs={}) + thr.start() + + +def update_mac_addresses(): + mac_addresses = utils.get_mac_addresses() + figure.Photobooth.edit( + settings.RESIN_UUID, data={'mac_addresses': mac_addresses}) + + +def update_mac_addresses_async(): + thr = Thread(target=update_mac_addresses, args=(), kwargs={}) + thr.start() + + +def claim_new_codes(): + if Code.less_than_1000_left(): + logger.info('We are running out of codes, fetching new ones from API...') + new_codes = figure.Code.claim(data={'number': 10000}) + Code.bulk_insert(new_codes) + logger.info('New codes fetched and saved !') + +def is_online(): + # check if the device is online + ping_host = '8.8.8.8' + server = PingServer(ping_host) + b = server.is_active + server.close() + return b \ No newline at end of file diff --git a/figureraspbian/settings.py b/figureraspbian/settings.py index 1bbb6cf..738255f 100644 --- a/figureraspbian/settings.py +++ b/figureraspbian/settings.py @@ -21,76 +21,115 @@ def get_env_setting(setting, default=None): raise ImproperlyConfigured(error_msg) -DEBUG = get_env_setting('DEBUG', True) - +########## API CONFIGURATION # Http host of the API API_HOST = get_env_setting('API_HOST', 'http://localhost:8000') - -# Http host for static files -STATIC_HOST = get_env_setting('STATIC_HOST', API_HOST) - # Token to authenticate to the API TOKEN = get_env_setting('TOKEN', 'token') - RESIN_UUID = get_env_setting('RESIN_DEVICE_UUID', 'resin_uuid') +UPDATE_POLL_INTERVAL = int(get_env_setting('UPDATE_POLL_INTERVAL', 90)) +UPLOAD_PORTRAITS_INTERVAL = int(get_env_setting('UPLOAD_PORTRAITS_INTERVAL', 90)) +CLAIM_NEW_CODES_INTERVAL = int(get_env_setting('CLAIM_NEW_CODES_INTERVAL', 3600)) +NUMBER_OF_CODES_TO_CLAIM = int(get_env_setting('NUMBER_OF_CODES_TO_CLAIM', 5000)) +# Timezone information +DEFAULT_TIMEZONE = 'Europe/Paris' +########## END API CONFIGURATION +########## SQLITE CONFIGURATION # Root directory to store data -DATA_ROOT = get_env_setting('DATA_ROOT', '/Users/benoit/git/figure-raspbian') +SQLITE_FILEPATH = get_env_setting('SQLITE_FILEPATH', ':memory:') +########## SQLITE CONFIGURATION +######### STATIC FILES CONFIGURATION +# Http host for static files +STATIC_HOST = get_env_setting('STATIC_HOST', API_HOST) +TICKET_CSS_URL = "%s/%s" % (STATIC_HOST, 'static/css/ticket.css') +LOGO_FIGURE_URL = "%s/%s" % (STATIC_HOST, 'static/images/logo_figure.jpg') +BOOTING_TICKET_TEMPLATE_URL = "%s/%s" % (STATIC_HOST, 'static/ticket_templates/booting.html') # Root directory for static files STATIC_ROOT = get_env_setting('STATIC_ROOT', '/Users/benoit/git/figure-raspbian/static') +LOCAL_TICKET_CSS_URL = 'file://%s/ticket.css' % STATIC_ROOT +LOCAL_LOGO_FIGURE_URL = 'file://%s/logo_figure.jpg' % STATIC_ROOT +######### END STATIC FILES CONFIGURATION -# Root for media files +######### MEDIA CONFIGURATION MEDIA_ROOT = get_env_setting('MEDIA_ROOT', '/Users/benoit/git/figure-raspbian/media') IMAGE_ROOT = os.path.join(MEDIA_ROOT, 'images') PICTURE_ROOT = os.path.join(MEDIA_ROOT, 'pictures') TICKET_ROOT = os.path.join(MEDIA_ROOT, 'tickets') MEDIA_URL = 'file://%s' % MEDIA_ROOT -RAMDISK_ROOT = '/mnt/ramdisk' - -TICKET_CSS_URL = "%s/%s" % (STATIC_HOST, 'static/css/ticket.css') -LOCAL_TICKET_CSS_URL = 'file://%s/ticket.css' % STATIC_ROOT - -BOOTING_TICKET_TEMPLATE_URL = "%s/%s" % (STATIC_HOST, 'static/ticket_templates/booting.html') -LOGO_FIGURE_URL = "%s/%s" % (STATIC_HOST, 'static/images/logo_figure.jpg') -LOCAL_LOGO_FIGURE_URL = 'file://%s/logo_figure.jpg' % STATIC_ROOT +RAMDISK_ROOT = get_env_setting('RAMDISK_ROOT', '/mnt/ramdisk') +######### END MEDIA CONFIGURATION -# Path to PhantomJS executable +######### PHANTOMJS CONFIGURATION PHANTOMJS_PATH = get_env_setting('PHANTOMJS_PATH', '/usr/local/bin/phantomjs') +######### END PHANTOMJS CONFIGURATION +######### I/O CONFIGURATION +IO_INTERFACE = get_env_setting('IO_INTERFACE', 'GPIOZERO') # Pin used to trigger the process -BUTTON_PIN = get_env_setting('BUTTON_PIN', 0) +BUTTON_PIN = int(get_env_setting('BUTTON_PIN', 4)) +REMOTE_RELEASE_CONNECTOR_PIN = int(get_env_setting('REMOTE_RELEASE_CONNECTOR_PIN', 5)) +DOOR_LOCK_PIN = int(get_env_setting('DOOR_LOCK_PIN', 12)) +SHUTDOWN_PIN = int(get_env_setting('SHUTDOWN_PIN', 19)) +######### END I/O CONFIGURATION -# Timezone information -DEFAULT_TIMEZONE = 'Europe/Paris' - -# Camera config +######### CAMERA CONFIGURATION APERTURE = int(get_env_setting('APERTURE', 11)) SHUTTER_SPEED = int(get_env_setting('SHUTTER_SPEED', 39)) ISO = int(get_env_setting('ISO', 3)) +WHITE_BALANCE = int(get_env_setting('WHITE_BALANCE', 6)) CAPTURE_DELAY = float(get_env_setting('CAPTURE_DELAY', 1.0)) -CAMERA_TRIGGER_TYPE = get_env_setting('CAMERA_TRIGGER_TYPE', 'TETHERED') -CAMERA_REMOTE_RELEASE_CONNECTOR_PIN = int(get_env_setting('CAMERA_REMOTE_RELEASE_CONNECTOR_PIN', 1)) +CAMERA_TRIGGER_TYPE = get_env_setting('CAMERA_TRIGGER_TYPE', 'GPHOTO2') +CAMERA_FOCUS_STEPS = int(get_env_setting('CAMERA_FOCUS_STEPS', 20)) +######### END CAMERA CONFIGURATION + +######## TICKET TEMPLATE CONFIGURATION +TICKET_TEMPLATE_PICTURE_SIZE = int(get_env_setting('TICKET_TEMPLATE_PICTURE_SIZE', 576)) +######## END TICKET TEMPLATE CONFIGURATION + +######### IMAGE ENHANCEMENT CONFIGURATION +# http://effbot.org/imagingbook/imageenhance.htm +CONTRAST_FACTOR = float(get_env_setting('CONTRAST_FACTOR', 1.0)) +SHARPNESS_FACTOR = float(get_env_setting('SHARPNESS_FACTOR', 1.0)) +######### END IMAGE ENHANCEMENT CONFIGURATION -# Printer config +######### PRINTER CONFIGURATION PRINTER_SPEED = int(get_env_setting('PRINTER_SPEED', 2)) PRINTER_MAX_WIDTH = int(get_env_setting('PRINTER_MAX_WIDTH', 576)) - # Paper roll length in cm PAPER_ROLL_LENGTH = int(get_env_setting('PAPER_ROLL_LENGTH', 8000)) PIXEL_CM_RATIO = float(get_env_setting('PIXEL_CM_RATIO', 75.59)) - # Number of line feed at the end of the ticket LINE_FEED_COUNT = int(get_env_setting('LINE_FEED_COUNT', 5)) +######### END PRINTER CONFIGURATION -WIFI_ON = int(get_env_setting('WIFI_ON', 0)) - +######### DOOR LOCK CONFIGURATION DOOR_OPENING_DELAY = int(get_env_setting('DOOR_OPENING_DELAY', 5)) DOOR_OPENING_TIME = int(get_env_setting('DOOR_OPENING_TIME', 10)) +######## END DOOR LOCK CONFIGURATION -UPDATE_POLL_INTERVAL = int(get_env_setting('UPDATE_POLL_INTERVAL', 90)) -UPLOAD_PORTRAITS_INTERVAL = int(get_env_setting('UPLOAD_PORTRAITS_INTERVAL', 90)) +######## RTC CONFIGURATION +RTC = get_env_setting('RTC', '') +RTC_SCLK_PIN = int(get_env_setting('RTC_SLCK_PIN', 3)) +RTC_SDAT_PIN = int(get_env_setting('RTC_SDAT_PIN', 2)) +RTC_RST_PIN = int(get_env_setting('RTC_RST_PIN', 13)) +######## END RTC CONFIGURATION + +######## WIFI CONFIGURATION +WIFI_ON = int(get_env_setting('WIFI_ON', 0)) +######## END WIFI CONFIGURATION + +####### SERVER CONFIGURATION +SERVER_ON = int(get_env_setting('SERVER_ON', 0)) +####### END SERVER CONFIGURATION +######## LOG CONFIGURATION LOG_FORMAT = "[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s" +######## END LOG CONFIGURATION +######## RESINIO SUPERVISOR CONFIGURATION +RESIN_SUPERVISOR_ADDRESS = get_env_setting('RESIN_SUPERVISOR_ADDRESS', '') +RESIN_SUPERVISOR_API_KEY = get_env_setting('RESIN_SUPERVISOR_API_KEY', '') +######## END RESINIO SUPERVISOR CONFIGURATION diff --git a/figureraspbian/tests/__init__.py b/figureraspbian/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/figureraspbian/tests/test_app.py b/figureraspbian/tests/test_app.py new file mode 100644 index 0000000..4eddaf9 --- /dev/null +++ b/figureraspbian/tests/test_app.py @@ -0,0 +1,71 @@ + +from unittest import TestCase +import mock +import sys +from datetime import datetime + +RPi = mock.Mock() +sys.modules['RPi'] = RPi +RPi.GPIO = mock.Mock() +sys.modules['RPi.GPIO'] = RPi.GPIO +webkit2png = mock.Mock() +sys.modules['figureraspbian.webkit2png'] = webkit2png + +from ..app import App + + +class AppTestCase(TestCase): + + @mock.patch("figureraspbian.app.is_online") + @mock.patch("figureraspbian.app.download_booting_ticket_template") + @mock.patch("figureraspbian.app.download_ticket_stylesheet") + @mock.patch("figureraspbian.app.update") + @mock.patch("figureraspbian.app.claim_new_codes") + @mock.patch("figureraspbian.app.update_mac_addresses_async") + @mock.patch("figureraspbian.app.get_photobooth") + @mock.patch("figureraspbian.app.set_intervals") + @mock.patch("figureraspbian.app.Button") + def test_init_is_online(self, Button, set_intervals, get_photobooth, update_mac_addresses_async, claim_new_codes, + update, download_ticket_stylesheet, download_booting_ticket_template, is_online): + is_online.return_value = True + button = mock.Mock() + Button.factory.return_value = button + + App() + + self.assertTrue(download_booting_ticket_template.called) + self.assertTrue(download_ticket_stylesheet.called) + self.assertTrue(update.called) + self.assertTrue(claim_new_codes.called) + self.assertTrue(update_mac_addresses_async.called) + self.assertTrue(get_photobooth.called) + self.assertTrue(set_intervals.called) + + @mock.patch("figureraspbian.app.is_online") + @mock.patch("figureraspbian.app.get_photobooth") + @mock.patch("figureraspbian.app.set_intervals") + @mock.patch("figureraspbian.app.set_system_time") + @mock.patch("figureraspbian.app.Button") + @mock.patch("figureraspbian.app.RTC") + def test_init_is_offline(self, RTC, Button, set_system_time, _1, _2, is_online): + """ it should set clock from hardware clock""" + is_online.return_value = False + + button = mock.Mock() + Button.factory.return_value = button + + rtc = mock.Mock() + RTC.factory.return_value = rtc + + dt = datetime(2017, 1, 1) + rtc.read_datetime.return_value = dt + + App() + + set_system_time.assert_called_with(dt) + + + + + + diff --git a/figureraspbian/tests/test_db.py b/figureraspbian/tests/test_db.py new file mode 100644 index 0000000..facc589 --- /dev/null +++ b/figureraspbian/tests/test_db.py @@ -0,0 +1,29 @@ + +from peewee import * + +from unittest import TestCase +from ..db import Database + + +class DatabaseTestCase(TestCase): + + def test_wrap_database(self): + """ it should wrap an SQLiteDatabase and return a base model""" + + db = Database() + db.connect_db() + + self.assertIsNotNone(db.database) + self.assertIsNotNone(db.Model) + + class TestModel(db.Model): + foo = CharField() + + db.database.create_tables([TestModel]) + + tables = db.database.get_tables() + self.assertEqual(len(tables), 1) + + db.close_db() + + diff --git a/figureraspbian/tests/test_decorators.py b/figureraspbian/tests/test_decorators.py new file mode 100644 index 0000000..efdcebc --- /dev/null +++ b/figureraspbian/tests/test_decorators.py @@ -0,0 +1,30 @@ + +from unittest import TestCase +from threading import RLock, Thread + +from ..decorators import execute_if_not_busy +from ..exceptions import DevicesBusy + + +class DecoratorsTestCase(TestCase): + + def test_execute_if_not_busy(self): + """ it should execute a function only if the underlying lock is available """ + + rlock = RLock() + + @execute_if_not_busy(rlock) + def f(): + return True + + self.assertTrue(f()) + + def acquire_lock(): + rlock.acquire() + + th = Thread(target=acquire_lock) + th.start() + th.join() + + with self.assertRaises(DevicesBusy): + f() \ No newline at end of file diff --git a/figureraspbian/tests/test_main.py b/figureraspbian/tests/test_main.py new file mode 100644 index 0000000..0cfaf2d --- /dev/null +++ b/figureraspbian/tests/test_main.py @@ -0,0 +1,39 @@ +from unittest import TestCase +import mock +import sys + +RPi = mock.Mock() +sys.modules['RPi'] = RPi +RPi.GPIO = mock.Mock() +sys.modules['RPi.GPIO'] = RPi.GPIO + +webkit2png = mock.Mock() +sys.modules['figureraspbian.webkit2png'] = webkit2png + + +from ..__main__ import ShutdownHook, create_tables +from ..db import db +from ..models import get_all_models + + +class MainTestCase(TestCase): + + @mock.patch("figureraspbian.__main__.Button") + @mock.patch("figureraspbian.__main__.requests") + @mock.patch("figureraspbian.__main__.settings") + def test_shutdown_hook(self, settings, requests, Button): + """ it should call resin io shutdown endpoint when power off is detected """ + button = mock.Mock() + Button.factory.return_value = button + settings.RESIN_SUPERVISOR_ADDRESS = 'resin_supervisor_address' + settings.RESIN_SUPERVISOR_API_KEY = 'resin_supervisor_api_key' + ShutdownHook() + button.when_pressed() + requests.post.assert_called_once_with("resin_supervisor_address/v1/shutdown?apikey=resin_supervisor_api_key", + data={'force': True}) + + def test_create_tables(self): + """ it should create tables for all models """ + db.database.drop_tables(get_all_models(), safe=True) + create_tables() + self.assertEqual(len(db.database.get_tables()), 10) diff --git a/figureraspbian/tests/test_models.py b/figureraspbian/tests/test_models.py new file mode 100644 index 0000000..0547509 --- /dev/null +++ b/figureraspbian/tests/test_models.py @@ -0,0 +1,337 @@ +# -*- coding: utf8 -*- + +from unittest import TestCase +import mock + +from ..models import get_all_models, TicketTemplate, Text, Image, ImageVariable, TextVariable, Code, Photobooth +from ..models import Place, Event +from ..db import db +from .. import settings + + +class TextTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + + def tearDown(self): + db.close_db() + + def test_serialize(self): + """ it should serialize object """ + data = {'id': 1, 'value': 'some text'} + text = Text.create(**data) + serialized = text.serialize() + expected = {'id': 1, 'text': 'some text'} + self.assertEqual(serialized, expected) + + def test_update_or_create_does_not_exist(self): + """ it should create a new instance if the given text does not exist """ + data = {'id': 1, 'text': 'some text'} + self.assertEqual(Text.select().count(), 0) + Text.update_or_create(data, None) + self.assertEqual(Text.select().count(), 1) + text = Text.get(Text.id == 1) + self.assertEqual(text.serialize(), data) + + def test_update_or_create_already_exists(self): + """ it should update the fields of the already existing instance """ + data = {'id': 1, 'value': 'some text'} + Text.create(**data) + self.assertEqual(Text.select().count(), 1) + data.pop('value') + data['text'] = 'changed' + Text.update_or_create(data, None) + self.assertEqual(Text.select().count(), 1) + text = Text.get(Text.id == 1) + self.assertEqual(text.value, 'changed') + + +class ImageTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + + def tearDown(self): + db.close_db() + + def test_serialize(self): + """ it should serialize image instance into dict """ + data = {'id': 1, 'path': '/path/to/image'} + image = Image.create(**data) + serialized = image.serialize() + expected = {'id': 1, 'name': 'image'} + self.assertEqual(serialized, expected) + + @mock.patch("figureraspbian.models.utils.download") + def test_update_or_create_does_not_exists(self, mock_download): + """ it should create an image if it does not exist and download the corresponding file """ + expected_path = '/path/to/image' + mock_download.return_value = expected_path + data = {'id': 1, 'image': 'https://url/to/image'} + self.assertEqual(Image.select().count(), 0) + Image.update_or_create(data) + self.assertEqual(Image.select().count(), 1) + mock_download.assert_called_with(data['image'], settings.IMAGE_ROOT) + image = Image.get(Image.id == 1) + self.assertEqual(image.path, expected_path) + + @mock.patch("figureraspbian.models.utils.download") + def test_update_or_create_already_exist(self, mock_download): + """ it should update image and download new image file """ + original_path = "/path/to/image1" + Image.create(id=1, path=original_path) + expected_path = "/path/to/image2" + mock_download.return_value = expected_path + self.assertEqual(Image.select().count(), 1) + data = {'id': 1, 'image': 'https://url/to/image2', 'name': 'image2'} + Image.update_or_create(data) + mock_download.assert_called_with(data['image'], settings.IMAGE_ROOT) + image = Image.get(Image.id == 1) + self.assertEqual(image.path, expected_path) + + +class TextVariableTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + + def tearDown(self): + db.close_db() + + def test_serialize(self): + """ it should serialize text variable instance into Python dict """ + data = {'id': 1, 'name': 'variable', 'mode': 'sequential'} + text_variable = TextVariable.create(**data) + serialized = text_variable.serialize() + expected = {'items': [], 'mode': 'sequential', 'id': 1, 'name': 'variable'} + self.assertEqual(serialized, expected) + + def test_update_or_create_does_not_exist(self): + """ it should create a text variable if it does not exist """ + data = {'id': 1, 'name': 'variable', 'mode': 'sequential', 'items': []} + self.assertEqual(TextVariable.select().count(), 0) + TextVariable.update_or_create(data, None) + self.assertEqual(TextVariable.select().count(), 1) + + def test_update_or_create_already_exists(self): + """ it should update the fields of the already existing instance """ + data = {'id': 1, 'name': 'variable', 'mode': 'sequential', 'items': []} + TextVariable.create(**data) + self.assertEqual(TextVariable.select().count(), 1) + data['name'] = 'changed' + TextVariable.update_or_create(data, None) + self.assertEqual(TextVariable.select().count(), 1) + text = TextVariable.get(TextVariable.id == 1) + self.assertEqual(text.name, 'changed') + + +class ImageVariableTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + + def tearDown(self): + db.close_db() + + def test_serialize(self): + """ it should serialize an instance into Python dict """ + data = {'id': 1, 'name': 'variable', 'mode': 'sequential', 'items': []} + image = ImageVariable.create(**data) + serialized = image.serialize() + expected = {'items': [], 'mode': 'sequential', 'id': 1, 'name': 'variable'} + self.assertEqual(serialized, expected) + + def test_update_or_create_does_not_exist(self): + """ it should create an image variable if it does not exist """ + data = {'id': 1, 'name': 'variable', 'mode': 'sequential', 'items': []} + self.assertEqual(ImageVariable.select().count(), 0) + ImageVariable.update_or_create(data, None) + self.assertEqual(ImageVariable.select().count(), 1) + + def test_update_or_create_already_exist(self): + """ it should update fields of an already existing instance """ + data = {'id': 1, 'name': 'variable', 'mode': 'sequential', 'items': []} + ImageVariable.create(**data) + self.assertEqual(ImageVariable.select().count(), 1) + data['name'] = 'changed' + ImageVariable.update_or_create(data) + self.assertEqual(ImageVariable.select().count(), 1) + image_variable = ImageVariable.get(id=1) + image_variable.name = 'changed' + + +class TicketTemplateTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + + def tearDown(self): + db.close_db() + + def test_serialize(self): + data = {'html': '', 'modified': '2015-05-11T08:31:01Z', 'title': 'foo', 'description': 'bar', 'id': 1} + ticket_template = TicketTemplate.create(**data) + serialized = ticket_template.serialize() + expected = { + 'description': 'bar', + 'title': 'foo', + 'modified': '2015-05-11T08:31:01Z', + 'image_variables': [], + 'html': '', + 'images': [], + 'id': 1, + 'text_variables': [] + } + self.assertEqual(serialized, expected) + + def test_update_or_create_does_not_exists(self): + """ it should create a ticket template if it does not exist """ + data = { + 'id': 1, + 'html': '', + 'modified': '2015-05-11T08:31:01Z', + 'title': 'foo', + 'description': 'bar', + 'text_variables': [], + 'images': [], + 'image_variables': [] + } + self.assertEqual(TicketTemplate.select().count(), 0) + TicketTemplate.update_or_create(data) + self.assertEqual(TicketTemplate.select().count(), 1) + ticket_template = TicketTemplate.get(TicketTemplate.id == 1) + self.assertEqual(ticket_template.serialize(), data) + + def test_update_or_create__already_exists(self): + """ it should update fields of the already existing instance """ + data = { + 'id': 1, + 'html': '', + 'modified': '2015-05-11T08:31:01Z', + 'title': 'foo', + 'description': 'bar', + 'text_variables': [], + 'images': [], + 'image_variables': [] + } + TicketTemplate.create(**data) + self.assertEqual(TicketTemplate.select().count(), 1) + data['title'] = 'changed' + data['html'] = 'changed' + data['description'] = 'changed' + TicketTemplate.update_or_create(data) + self.assertEqual(TicketTemplate.select().count(), 1) + ticket_template = TicketTemplate.get(TicketTemplate.id == 1) + self.assertEqual(ticket_template.serialize(), data) + + +class PhotoboothTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + self.uuid = "456d7e66247320147eda0a490df0c88a170f60f4378c7c1e3e77f845963c2e" + Photobooth.get_or_create(uuid=self.uuid) + + def tearDown(self): + db.close_db() + + @mock.patch("figureraspbian.models.utils.download") + @mock.patch("figureraspbian.models.settings") + def test_update_from_api_data(self, mock_settings, mock_download): + """ it should update the photobooth """ + uuid = "456d7e66247320147eda0a490df0c88a170f60f4378c7c1e3e77f845963c2e" + mock_settings.RESIN_UUID = uuid + Photobooth.get_or_create(uuid=uuid) + mock_download.return_value = '/path/to/image' + photobooth = Photobooth.get() + updated = { + "id": 1, + "serial_number": "FIG.00001", + "place": { + "id": 1, + "name": "Atelier Commode", + "tz": "Europe/Paris", + "modified": "2017-03-18T15:20:01.711000Z" + }, + "event": { + "id": 1, + "name": "Attention à la mousse", + "modified": "2016-09-01T08:29:21.705000Z" + }, + "ticket_template": { + "id": 1, + "modified": "2017-03-11T11:29:28Z", + "html": "", + "title": "", + "description": "", + "text_variables": [], + "image_variables": [], + "images": [] + } + } + r = photobooth.update_from_api_data(updated) + print(r) + photobooth = Photobooth.get() + self.assertEqual(Place.select().count(), 1) + self.assertEqual(Event.select().count(), 1) + self.assertEqual(TicketTemplate.select().count(), 1) + self.assertEqual(photobooth.serial_number, "FIG.00001") + self.assertEqual(photobooth.id, 1) + self.assertEqual(photobooth.place.id, 1) + self.assertEqual(photobooth.event.id, 1) + self.assertEqual(photobooth.ticket_template.id, 1) + + +class CodeTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + + def tearDown(self): + db.close_db() + + def test_pop(self): + """ it should pop a code from the available codes """ + codes = [ + {'value': 'CODE1'}, + {'value': 'CODE2'} + ] + for code in codes: + Code.create(**code) + self.assertEqual(Code.select().count(), 2) + code = Code.pop() + self.assertEqual(code, 'CODE1') + self.assertEqual(Code.select().count(), 1) + code = Code.pop() + self.assertEqual(code, 'CODE2') + self.assertEqual(Code.select().count(), 0) + + def test_bulk_insert(self): + """ it should bulk insert an array of codes """ + + codes = ["%05d" % n for n in range(0, 2000)] + Code.bulk_insert(codes) + self.assertEqual(Code.select().count(), len(codes)) + + def test_less_than_1000_is_false(self): + """ it should return true if we have less than 1000 codes left, false otherwise """ + codes = ["%05d" % n for n in range(0, 1000)] + Code.bulk_insert(codes) + self.assertFalse(Code.less_than_1000_left()) + Code.pop() + self.assertTrue(Code.less_than_1000_left()) diff --git a/figureraspbian/tests/test_photobooth.py b/figureraspbian/tests/test_photobooth.py new file mode 100644 index 0000000..e700eba --- /dev/null +++ b/figureraspbian/tests/test_photobooth.py @@ -0,0 +1,261 @@ +# -*- coding: utf8 -*- + +from unittest import TestCase +import mock +from datetime import datetime +import sys + +webkit2png = mock.Mock() +sys.modules['figureraspbian.webkit2png'] = webkit2png + +from PIL import Image + +from ..db import db +from ..models import get_all_models, Photobooth as PhotoboothModel, Code, TicketTemplate +from .. import settings +from ..photobooth import Photobooth +from ..exceptions import OutOfPaperError + + +class PhotoboothTestCase(TestCase): + + def setUp(self): + db.connect_db() + db.database.drop_tables(get_all_models(), safe=True) + db.database.create_tables(get_all_models()) + PhotoboothModel.get_or_create(uuid=settings.RESIN_UUID) + Code.create(value="CODE1") + + def tearDown(self): + db.close_db() + + @mock.patch("figureraspbian.devices.camera.Camera.factory") + @mock.patch("figureraspbian.devices.printer.Printer.factory") + @mock.patch("figureraspbian.devices.door_lock.DoorLock.factory") + def test_initialize(self, door_lock_factory, printer_factory, camera_factory): + camera = mock.Mock() + printer = mock.Mock() + door_lock = mock.Mock() + door_lock_factory.return_value = door_lock + printer_factory.return_value = printer + camera_factory.return_value = camera + photobooth = Photobooth() + camera.clear_space.assert_called_once() + self.assertTrue(photobooth.ready) + + + @mock.patch("figureraspbian.devices.camera.Camera.factory") + @mock.patch("figureraspbian.devices.printer.Printer.factory") + @mock.patch("figureraspbian.devices.door_lock.DoorLock.factory") + def test_trigger_paper_empty(self, door_lock_factory, printer_factory, camera_factory): + """ it should check if paper is present before taking a picture """ + camera = mock.Mock() + printer = mock.Mock() + door_lock = mock.Mock() + door_lock_factory.return_value = door_lock + printer_factory.return_value = printer + camera_factory.return_value = camera + + printer.paper_present.return_value = False + _p = PhotoboothModel.get() + _p.paper_level = 0.0 + _p.save() + photobooth = Photobooth() + photobooth._trigger() + + self.assertEqual(camera.capture.call_count, 0) + + printer.paper_present.return_value = True + snapshot = open("./test_snapshot.jpg").read() + camera.capture.return_value = snapshot + + photobooth.render_print_and_upload = mock.Mock() + photobooth._trigger() + + photobooth.render_print_and_upload.assert_called_with(snapshot) + + + @mock.patch("figureraspbian.devices.camera.Camera.factory") + @mock.patch("figureraspbian.devices.printer.Printer.factory") + @mock.patch("figureraspbian.devices.door_lock.DoorLock.factory") + @mock.patch("figureraspbian.photobooth.datetime") + def test_set_context(self, mock_datetime, door_lock_factory, printer_factory, camera_factory): + camera = mock.Mock() + printer = mock.Mock() + door_lock = mock.Mock() + door_lock_factory.return_value = door_lock + printer_factory.return_value = printer + camera_factory.return_value = camera + now = datetime(2017, 1, 1) + mock_datetime.now.return_value = now + + photobooth = Photobooth() + self.assertIsNone(photobooth.context) + + photobooth.set_context() + expected = {'date': now, 'code': u'CODE1', 'counter': 0} + self.assertEqual(photobooth.context, expected) + + + @mock.patch("figureraspbian.devices.camera.Camera.factory") + @mock.patch("figureraspbian.devices.printer.Printer.factory") + @mock.patch("figureraspbian.devices.door_lock.DoorLock.factory") + @mock.patch("figureraspbian.photobooth.Image") + def test_render_ticket(self, mock_Image, door_lock_factory, printer_factory, camera_factory): + """ it should resize picture and render ticket from context """ + camera = mock.Mock() + printer = mock.Mock() + door_lock = mock.Mock() + door_lock_factory.return_value = door_lock + printer_factory.return_value = printer + camera_factory.return_value = camera + + picture = mock.Mock() + mock_Image.open.return_value = picture + picture.format = 'JPEG' + picture.resize.return_value = Image.open('./test_snapshot.jpg') + + photobooth = Photobooth() + now = datetime(2017, 1, 1) + photobooth.context = {'date': now, 'code': u'CODE1', 'counter': 0} + tt = { + 'title': 'foo', + 'description': 'bar', + 'html': '{{title}}{{description}}', + 'images': [], + 'image_variables': [], + 'text_variables': [], + 'modified': datetime(2016, 1, 1) + } + photobooth.photobooth.ticket_template = TicketTemplate.create(**tt) + photobooth.photobooth.save() + rendered = photobooth.render_ticket(open('./test_snapshot.jpg').read()) + + size = settings.TICKET_TEMPLATE_PICTURE_SIZE + picture.resize.assert_called_once_with((size, size)) + expected = "foobar" + self.assertEqual(rendered, expected) + + + @mock.patch("figureraspbian.devices.camera.Camera.factory") + @mock.patch("figureraspbian.devices.printer.Printer.factory") + @mock.patch("figureraspbian.devices.door_lock.DoorLock.factory") + @mock.patch("figureraspbian.photobooth.webkit2png") + @mock.patch("figureraspbian.photobooth.request") + def test_render_print_and_upload(self, request, webkit2png, door_lock_factory, printer_factory, camera_factory): + """ it should render a ticket, print it and upload it """ + camera = mock.Mock() + printer = mock.Mock() + door_lock = mock.Mock() + door_lock_factory.return_value = door_lock + printer_factory.return_value = printer + camera_factory.return_value = camera + + webkit2png.get_screenshot.return_value = open('test_ticket.png').read() + printer.print_image.return_value = 700 + + p = PhotoboothModel.get() + data = { + "id": 1, + "serial_number": "FIG.00001", + "resin_uuid": "456d7e66247320147eda0a490df0c88a170f60f4378c7c1e3e77f845963c2e", + "place": { + "id": 128, + "name": "Le Bar à Bulles", + "tz": "Europe/Paris", + "modified": "2017-05-04T09:33:21.988428Z" + }, + "event": None, + "ticket_template": { + "id": 475, + "modified": "2017-01-27T08:53:15Z", + "html": "{{title}}{{description}}", + "title": "Bar à Bulles", + "description": "La Machine du Moulin Rouge", + "text_variables": [], + "image_variables": [], + "images": [], + } + } + + p.update_from_api_data(data) + picture = open("./test_snapshot.jpg").read() + + photobooth = Photobooth() + photobooth.render_print_and_upload(picture) + + expected = u'Bar \xe0 BullesLa Machine du Moulin Rouge' + webkit2png.get_screenshot.assert_called_once_with(expected) + + self.assertEqual(printer.print_image.call_count, 1) + + self.assertEqual(photobooth.counter, 1) + self.assertAlmostEqual(photobooth.paper_level, 99.88, delta=0.1) + + self.assertEqual(request.upload_portrait_async.call_count, 1) + request.update_paper_level_async.assert_called_with(photobooth.paper_level) + + + @mock.patch("figureraspbian.devices.camera.Camera.factory") + @mock.patch("figureraspbian.devices.printer.Printer.factory") + @mock.patch("figureraspbian.devices.door_lock.DoorLock.factory") + @mock.patch("figureraspbian.photobooth.webkit2png") + @mock.patch("figureraspbian.photobooth.request") + def test_render_print_and_upload_out_of_paper(self, request, webkit2png, door_lock_factory, + printer_factory, camera_factory): + """ it should catch OutOfPaperException and set paper level to 0 """ + camera = mock.Mock() + printer = mock.Mock() + door_lock = mock.Mock() + door_lock_factory.return_value = door_lock + printer_factory.return_value = printer + camera_factory.return_value = camera + + webkit2png.get_screenshot.return_value = open('test_ticket.png').read() + printer.print_image.side_effect = [OutOfPaperError] + + p = PhotoboothModel.get() + data = { + "id": 1, + "serial_number": "FIG.00001", + "resin_uuid": "456d7e66247320147eda0a490df0c88a170f60f4378c7c1e3e77f845963c2e", + "place": { + "id": 128, + "name": "Le Bar à Bulles", + "tz": "Europe/Paris", + "modified": "2017-05-04T09:33:21.988428Z" + }, + "event": None, + "ticket_template": { + "id": 475, + "modified": "2017-01-27T08:53:15Z", + "html": "{{title}}{{description}}", + "title": "Bar à Bulles", + "description": "La Machine du Moulin Rouge", + "text_variables": [], + "image_variables": [], + "images": [], + } + } + + p.update_from_api_data(data) + picture = open("./test_snapshot.jpg").read() + + photobooth = Photobooth() + photobooth.render_print_and_upload(picture) + + self.assertEqual(photobooth.paper_level, 0.0) + request.update_paper_level_async.assert_called_with(photobooth.paper_level) + + + + + + + + + + + + + diff --git a/figureraspbian/tests/test_request.py b/figureraspbian/tests/test_request.py new file mode 100644 index 0000000..0b2fe51 --- /dev/null +++ b/figureraspbian/tests/test_request.py @@ -0,0 +1,121 @@ + +from unittest import TestCase +from datetime import datetime +import mock + +from .. import request + + +class RequestTestCase(TestCase): + + @mock.patch("figureraspbian.request.figure") + def test_upload_portrait(self, mock_figure): + """ it should upload a portrait to Figure API """ + + with open("test_snapshot.jpg") as f: + picture = f.read() + with open("test_ticket.png") as f: + ticket = f.read() + + portrait = { + 'picture': picture, + 'ticket': ticket, + 'taken': datetime(2017, 1, 1), + 'place': 1, + 'event': 1, + 'photobooth': 1, + 'code': "CODE1", + 'filename': "Figure.jpg" + } + request.upload_portrait(portrait) + expected_data = {'taken': portrait['taken'], 'code': portrait['code'], 'place': portrait['place'], + 'event': portrait['event'], 'photobooth': portrait['photobooth']} + expected_files = { + 'picture_color': (portrait['filename'], portrait['picture']), + 'ticket': (portrait['filename'], portrait['ticket']) + } + mock_figure.Portrait.create.assert_called_once_with(files=expected_files, data=expected_data) + + @mock.patch("figureraspbian.request.figure") + @mock.patch("figureraspbian.request.utils.write_file") + @mock.patch("figureraspbian.request.Portrait") + def test_upload_portrait_raise_exception(self, mock_Portrait, mock_write_file, mock_figure): + """ it should save portrait to local file system """ + with open("test_snapshot.jpg") as f: + picture = f.read() + with open("test_ticket.png") as f: + ticket = f.read() + + portrait = { + 'picture': picture, + 'ticket': ticket, + 'taken': datetime(2017, 1, 1), + 'place': 1, + 'event': 1, + 'photobooth': 1, + 'code': "CODE1", + 'filename': "Figure.jpg" + } + + class MyException(Exception): + pass + + mock_figure.Portrait.create.side_effect = [MyException()] + + request.upload_portrait(portrait) + + self.assertEqual(mock_write_file.call_count, 2) + self.assertEqual(mock_Portrait.create.call_count, 1) + + @mock.patch("figureraspbian.request.figure") + @mock.patch("figureraspbian.request.Code") + def test_claim_new_codes(self, mock_Code, mock_figure): + """ it should claim new codes from API if less than 100 codes left """ + mock_Code.less_than_1000_left.return_value = True + codes = ["%05d" % i for i in range(0, 1000)] + mock_figure.Code.claim.return_value = codes + request.claim_new_codes() + mock_Code.bulk_insert.assert_called_once_with(codes) + + @mock.patch("figureraspbian.request.figure") + @mock.patch("figureraspbian.models.settings") + def test_update(self, settings, figure): + """ it should fetch updated data from API and update local data """ + + data = { + "id": 1, + "serial_number": "FIG.00012", + "resin_uuid": "8c18223ebb19aa44cb23bc8e710de4f9", + "place": { + "id": 1, + "name": "Atelier Commode", + "tz": "Europe/Paris", + "modified": "2017-03-17T09:09:03.268825Z" + }, + "event": None, + "ticket_template": { + "id": 1, + "modified": "2017-04-13T16:54:19.987969Z", + "html": "\n", + "title": "Atelier Commode", + "description": "", + "text_variables": [], + "image_variables": [], + "images": [], + } + } + figure.Photobooth.get.return_value = data + from ..models import get_all_models + from ..db import db + from ..models import Photobooth + db.database.create_tables(get_all_models(), True) + uuid = "8c18223ebb19aa44cb23bc8e710de4f9" + settings.RESIN_UUID = uuid + Photobooth.get_or_create(uuid=uuid) + request.update() + updated = Photobooth.get() + self.assertIsNotNone(updated.place) + self.assertIsNotNone(updated.ticket_template) + self.assertIsNotNone(updated.serial_number) + + diff --git a/figureraspbian/tests/test_utils.py b/figureraspbian/tests/test_utils.py new file mode 100644 index 0000000..c4ef98d --- /dev/null +++ b/figureraspbian/tests/test_utils.py @@ -0,0 +1,138 @@ + +from unittest import TestCase +import mock +from .. import utils +import tempfile +import time +from datetime import datetime +import netifaces +import os + +from PIL import Image + + +class TestUtils(TestCase): + + def test_url2name(self): + """ it should convert a file url to its basename """ + url = 'http://api.figuredevices.com/static/css/ticket.css' + name = utils.url2name(url) + expected = 'ticket.css' + self.assertEqual(name, expected) + + @mock.patch('figureraspbian.utils.urllib2') + def test_download(self, mock_urllib2): + """ it should download file if not present in local file system and return file path """ + tempdir = tempfile.mkdtemp() + mock_filelike = mock.Mock() + mock_urllib2.urlopen.return_value = mock_filelike + mock_filelike.read.return_value = 'file content' + utils.download('https://path/to/some/file.txt', tempdir) + self.assertEqual(mock_urllib2.urlopen.call_count, 1) + # try downloading again, it should do nothing as file is already present + utils.download('https://path/to/some/file.txt', tempdir) + self.assertEqual(mock_urllib2.urlopen.call_count, 1) + # forcing the download should overwrite the file + utils.download('https://path/to/some/file.txt', tempdir, force=True) + self.assertEqual(mock_urllib2.urlopen.call_count, 2) + + @mock.patch('figureraspbian.utils.logger.info') + def test_timeit(self, mock_info): + """ + timeit should log time spend in a function + """ + @utils.timeit + def sleep(): + time.sleep(0.5) + return "wake up" + r = sleep() + assert r == "wake up" + assert mock_info.called + + def test_get_filename(self): + filename = utils.get_file_name("CODES") + assert filename == 'Figure_N5rIARTnVC1ySp0.jpg' + + def test_get_data_url(self): + im = Image.new('L', (1, 1)) + im.format = 'JPEG' + data_url = utils.get_data_url(im) + expected = ("data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh" + "0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQF" + "BgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJi" + "coKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2" + "t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/APn+v//Z") + self.assertEqual(data_url, expected) + + def test_pixels2cm(self): + """ + pixels2cm should convert image pixels into how many cm will actually be printed on the ticket + """ + cm = utils.pixels2cm(1098) + self.assertAlmostEqual(cm, 14.5, delta=0.05) + + def test_new_paper_level(self): + """ it should calculate new paper level based on old paper level and ticket printed length """ + new_paper_level = utils.new_paper_level(80.0, 900) + self.assertAlmostEqual(new_paper_level, 79.85, delta=0.01) + + def test_new_aper_level_below_1(self): + """ it should reset paper level to 10 if we go below 1 """ + new_paper_level = utils.new_paper_level(1.0, 900) + self.assertEqual(new_paper_level, 10.0) + + @mock.patch("figureraspbian.utils.os.system") + def test_set_system_time(self, mock_system): + """ it should set system time from a datetime """ + dt = datetime(2017, 1, 1) + utils.set_system_time(dt) + mock_system.assert_called_with('date -s "2017-01-01 00:00:00"') + + @mock.patch("figureraspbian.utils.netifaces.ifaddresses") + @mock.patch("figureraspbian.utils.netifaces.interfaces") + def test_get_mac_addresses(self, mock_interfaces, mock_ifaddresses): + mock_interfaces.return_value = ['lo0', 'en0'] + mock_ifaddresses.side_effect = [ + {netifaces.AF_LINK: [{'addr': '3c:15:c2:e3:b9:4e'}]}, + {netifaces.AF_LINK: [{'addr': '72:00:04:84:f5:60'}]} + ] + mac_addresses = utils.get_mac_addresses() + expected = "lo0=3c:15:c2:e3:b9:4e,en0=72:00:04:84:f5:60" + self.assertEqual(mac_addresses, expected) + + def test_render_jinja_template(self): + """ it should render a jinja template stored in a file located at path with the given kwargs""" + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + template = "{{ foo }}{{ bar }}" + tmp_file.write(template) + path = tmp_file.name + rendered = utils.render_jinja_template(path, foo="foo", bar="bar") + os.remove(path) + expected = "foobar" + self.assertEqual(rendered, expected) + + @mock.patch("figureraspbian.utils.subprocess") + def test_get_usb_devices(self, mock_subprocess): + """ it should parse the output of lsusb """ + mock_subprocess.check_output.return_value = ( + "Bus 001 Device 005: ID 04b8:0e15 Seiko Epson Corp.\n" + "Bus 001 Device 006: ID 04a9:327f Canon, Inc.") + devices = utils.get_usb_devices() + expected = [ + {'device': '/dev/bus/usb/001/005', 'vendor_id': '04b8', 'tag': 'Seiko Epson Corp.', 'product_id': '0e15'}, + {'device': '/dev/bus/usb/001/006', 'vendor_id': '04a9', 'tag': 'Canon, Inc.', 'product_id': '327f'}] + self.assertEqual(devices, expected) + + def test_crop_to_square(self): + """ it should crop a rectangle image to a square """ + im = Image.new('L', (200, 100)) + cropped = utils.crop_to_square(im) + self.assertEqual(cropped.size, (100, 100)) + + def test_resize_preserve_ratio(self): + """ it should resize an image to a given height or width preserving the ratio """ + im = Image.new('L', (200, 100)) + resized = utils.resize_preserve_ratio(im, new_width=100) + self.assertEqual(resized.size[1], 50) + resized = utils.resize_preserve_ratio(im, new_height=10) + self.assertEqual(resized.size[0], 20) \ No newline at end of file diff --git a/figureraspbian/threads.py b/figureraspbian/threads.py index 0c4a419..8ea7709 100644 --- a/figureraspbian/threads.py +++ b/figureraspbian/threads.py @@ -1,8 +1,10 @@ -from threading import Thread, Event +from threading import Thread, Event, RLock _THREADS = set() +rlock = RLock() + def threads_shutdown(): while _THREADS: diff --git a/figureraspbian/utils.py b/figureraspbian/utils.py index b8c299e..33c5128 100644 --- a/figureraspbian/utils.py +++ b/figureraspbian/utils.py @@ -1,29 +1,28 @@ # -*- coding: utf8 -*- -import os -from os.path import basename, exists -import urllib -import logging import time -from urlparse import urlsplit -import cStringIO +import logging import base64 +import cStringIO +import netifaces +import re import subprocess +from os.path import join, basename, exists +import os +from urlparse import urlsplit +import urllib import urllib2 -from os.path import join import codecs -import io -from PIL import Image from hashids import Hashids +from PIL import ImageOps, ImageEnhance from jinja2 import Environment -import netifaces -import piexif -from figureraspbian import settings +import settings + -logging.basicConfig(format=settings.LOG_FORMAT, datefmt='%Y.%m.%d %H:%M:%S', level='INFO') logger = logging.getLogger(__name__) +hashids = Hashids(salt='Titi Vicky Benni') def url2name(url): @@ -34,6 +33,12 @@ def url2name(url): return basename(urllib.unquote(urlsplit(url)[2])) +def write_file(file, path): + """ Write a file to a specific path """ + with open(path, "wb") as f: + f.write(file) + + def download(url, path, force=False): """ Download a file from a remote url and copy it to the local path @@ -47,19 +52,11 @@ def download(url, path, force=False): return path_to_file -def write_file(file, path): - """ - Write a file to a specific path - """ - with open(path, "wb") as f: - f.write(file) - - -def read_file(path): - """ - Open a file and return its content - """ - return open(path, 'rb') +def get_file_name(code): + # TODO check for unicity + ascii = [ord(c) for c in code] + hash = hashids.encode(*ascii) + return "Figure_%s.jpg" % hash def timeit(func): @@ -73,75 +70,43 @@ def timed(*args, **kw): return timed -def crop_to_square(image_data): - """ convert a rectangle picture to a square shape """ - picture = Image.open(io.BytesIO(image_data)) - exif_dict = piexif.load(picture.info["exif"]) - w, h = picture.size - left = (w - h) / 2 - top = 0 - right = w - left - bottom = h - picture = picture.crop((left, top, right, bottom)) - w, h = picture.size - exif_dict["Exif"][piexif.ExifIFD.PixelXDimension] = w - exif_bytes = piexif.dump(exif_dict) - return picture, exif_bytes - - -@timeit -def get_base64_picture_thumbnail(picture): +def get_data_url(picture): buf = cStringIO.StringIO() - x = settings.PRINTER_MAX_WIDTH - picture.resize((x, x)).save(buf, "JPEG") - content = base64.b64encode(buf.getvalue()) + picture.save(buf, picture.format) + data = base64.b64encode(buf.getvalue()) buf.close() - return content - -@timeit -def get_pure_black_and_white_ticket(ticket_io): - ticket = Image.open(cStringIO.StringIO(ticket_io)) - ticket = ticket.convert('1') - ticket_path = join(settings.RAMDISK_ROOT, 'ticket.png') - ticket.save(ticket_path, ticket.format, quality=100) - _, ticket_length = ticket.size - return ticket_path, ticket_length + mime_type = 'image/%s' % picture.format.lower() + data_url = 'data:%s;base64,%s' % (mime_type, data) + return data_url -@timeit -def png2pos(path): - # TODO make png2pos support passing base64 file argument - speed_arg = '-s%s' % settings.PRINTER_SPEED - args = ['png2pos', '-r', speed_arg, '-aC', path] - my_env = os.environ.copy() - my_env['PNG2POS_PRINTER_MAX_WIDTH'] = str(settings.PRINTER_MAX_WIDTH) - p = subprocess.Popen(args, stdout=subprocess.PIPE, env=my_env) - pos_data, err = p.communicate() - if err: - raise err - return pos_data - - -hashids = Hashids(salt='Titi Vicky Benni') +def pixels2cm(pixels): + return float(pixels) / settings.PIXEL_CM_RATIO -def get_file_name(code): - # TODO check for unicity - ascii = [ord(c) for c in code] - hash = hashids.encode(*ascii) - return "Figure_%s.jpg" % hash +def new_paper_level(old_paper_level, ticket_length): + cm = pixels2cm(ticket_length) + new_paper_level = old_paper_level - (cm / float(settings.PAPER_ROLL_LENGTH)) * 100 + if new_paper_level <= 1: + # estimate is wrong, guess it's 10% + new_paper_level = 10 + return new_paper_level -def pixels2cm(pixels): - return float(pixels) / settings.PIXEL_CM_RATIO +def set_system_time(dt): + date_format = "%Y-%m-%d %H:%M:%S" + date_string = dt.strftime(date_format) + cmd = 'date -s "%s"' % date_string + os.system(cmd) def get_mac_addresses(): mac_addresses = [] interfaces = netifaces.interfaces() for interface in interfaces: - af_link = netifaces.ifaddresses(interface).get(netifaces.AF_LINK) - if af_link and len(af_link)>0: + ifaddresses = netifaces.ifaddresses(interface) + af_link = ifaddresses.get(netifaces.AF_LINK) + if af_link and len(af_link) > 0: addr = af_link[0].get('addr') if addr: mac_address = '%s=%s' % (interface, addr) @@ -156,8 +121,57 @@ def render_jinja_template(path, **kwargs): return template.render(kwargs) +def get_usb_devices(): + pattern = re.compile("Bus\s+(?P\d+)\s+Device\s+(?P\d+).+ID\s(?P\w+):(?P\w+)\s(?P.+)$", re.I) + df = subprocess.check_output("lsusb", shell=True) + # parse all usb devices + devices = [] + for i in df.split('\n'): + if i: + info = pattern.match(i) + if info: + dinfo = info.groupdict() + dinfo['device'] = '/dev/bus/usb/%s/%s' % (dinfo.pop('bus'), dinfo.pop('device')) + devices.append(dinfo) + return devices + + +def crop_to_square(pil_image): + """ convert a rectangle image to a square shape """ + w, h = pil_image.size + left = (w - h) / 2 + top = 0 + right = w - left + bottom = h + cropped = pil_image.crop((left, top, right, bottom)) + return cropped + +def resize_preserve_ratio(image, new_height=None, new_width=None): + if new_height: + (w, h) = image.size + if h != new_height: + new_width = int(new_height * w / float(h)) + resized = image.resize((new_width, new_height)) + return resized + elif new_width: + (w, h) = image.size + if w != new_width: + new_height = int(new_width * h / float(w)) + resized = image.resize((new_width, new_height)) + return resized + return image +def add_margin(image, border, color='white'): + """ add an horizontal margin to the image """ + return ImageOps.expand(image, border, color) +@timeit +def enhance_image(image): + contraster = ImageEnhance.Contrast(image) + image = contraster.enhance(settings.CONTRAST_FACTOR) + sharpener = ImageEnhance.Sharpness(image) + image = sharpener.enhance(settings.SHARPNESS_FACTOR) + return image \ No newline at end of file diff --git a/figureraspbian/webkit2png.py b/figureraspbian/webkit2png.py new file mode 100644 index 0000000..9c8b0aa --- /dev/null +++ b/figureraspbian/webkit2png.py @@ -0,0 +1,461 @@ +# +# webkit2png.py +# +# Creates screenshots of webpages using by QtWebkit. +# +# Copyright (c) 2014 Roland Tapken +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# +# Nice ideas "todo": +# - Add QTcpSocket support to create a "screenshot daemon" that +# can handle multiple requests at the same time. + +import time +import os +import logging + +from multiprocessing import Process + +from PyQt4.QtCore import * +from PyQt4.QtGui import * +from PyQt4.QtWebKit import * +from PyQt4.QtNetwork import * + +from .utils import timeit +import settings + + +logger = logging.getLogger(__name__) + + +SCREENSHOT_PATH = os.path.join(settings.RAMDISK_ROOT, 'screenshot.png') + + +def init_qtgui(): + """Initiates the QApplication environment using the given args.""" + if QApplication.instance(): + logger.debug("QApplication has already been instantiated. \ + Ignoring given arguments and returning existing QApplication.") + return QApplication.instance() + + return QApplication([]) + + +@timeit +def get_screenshot(html): + p = Process(target=_get_screenshot, args=(html,)) + p.start() + p.join() + with open(SCREENSHOT_PATH, 'rb') as f: + content = f.read() + return content + + +def _get_screenshot(html): + + def _render_screenshot(): + try: + renderer = WebkitRenderer(logger=logger) + with open(SCREENSHOT_PATH, 'wb') as _f: + renderer.render_to_file((html, ''), _f) + QApplication.exit(0) + except RuntimeError as e: + logger.error("main: %s" % e) + QApplication.exit(1) + + app = init_qtgui() + QTimer.singleShot(0, _render_screenshot) + app.exec_() + + +# Class for Website-Rendering. Uses QWebPage, which +# requires a running QtGui to work. +class WebkitRenderer(QObject): + """ + A class that helps to create 'screenshots' of webpages using + Qt's QWebkit. Requires PyQt4 library. + Use "render()" to get a 'QImage' object, render_to_bytes() to get the + resulting image as 'str' object or render_to_file() to write the image + directly into a 'file' resource. + """ + def __init__(self,**kwargs): + """ + Sets default values for the properties. + """ + + if not QApplication.instance(): + raise RuntimeError(self.__class__.__name__ + " requires a running QApplication instance") + QObject.__init__(self) + + # Initialize default properties + self.width = kwargs.get('width', 0) + self.height = kwargs.get('height', 0) + self.timeout = kwargs.get('timeout', 0) + self.wait = kwargs.get('wait', 0) + self.scaleToWidth = kwargs.get('scaleToWidth', 0) + self.scaleToHeight = kwargs.get('scaleToHeight', 0) + self.scaleRatio = kwargs.get('scaleRatio', 'keep') + self.format = kwargs.get('format', 'png') + self.logger = kwargs.get('logger', None) + + # Set this to true if you want to capture flash. + # Not that your desktop must be large enough for + # fitting the whole window. + self.grabWholeWindow = kwargs.get('grabWholeWindow', False) + self.renderTransparentBackground = kwargs.get('renderTransparentBackground', False) + self.ignoreAlert = kwargs.get('ignoreAlert', True) + self.ignoreConfirm = kwargs.get('ignoreConfirm', True) + self.ignorePrompt = kwargs.get('ignorePrompt', True) + self.interruptJavaScript = kwargs.get('interruptJavaScript', True) + self.encodedUrl = kwargs.get('encodedUrl', False) + self.cookies = kwargs.get('cookies', []) + + # Set some default options for QWebPage + self.qWebSettings = { + QWebSettings.JavascriptEnabled : False, + QWebSettings.PluginsEnabled : False, + QWebSettings.PrivateBrowsingEnabled : True, + QWebSettings.JavascriptCanOpenWindows : False + } + + + def render(self, res): + """ + Renders the given URL into a QImage object + """ + # We have to use this helper object because + # QApplication.processEvents may be called, causing + # this method to get called while it has not returned yet. + helper = _WebkitRendererHelper(self) + helper._window.resize( self.width, self.height ) + image = helper.render(res) + + # Bind helper instance to this image to prevent the + # object from being cleaned up (and with it the QWebPage, etc) + # before the data has been used. + image.helper = helper + + return image + + def render_to_file(self, res, file_object): + """ + Renders the image into a File resource. + Returns the size of the data that has been written. + """ + format = self.format # this may not be constant due to processEvents() + image = self.render(res) + qBuffer = QBuffer() + image.save(qBuffer, format) + file_object.write(qBuffer.buffer().data()) + return qBuffer.size() + + def render_to_bytes(self, res): + """Renders the image into an object of type 'str'""" + format = self.format # this may not be constant due to processEvents() + image = self.render(res) + qBuffer = QBuffer() + image.save(qBuffer, format) + return qBuffer.buffer().data() + +## @brief The CookieJar class inherits QNetworkCookieJar to make a couple of functions public. +class CookieJar(QNetworkCookieJar): + def __init__(self, cookies, qtUrl, parent=None): + QNetworkCookieJar.__init__(self, parent) + for cookie in cookies: + QNetworkCookieJar.setCookiesFromUrl(self, QNetworkCookie.parseCookies(QByteArray(cookie)), qtUrl) + + def allCookies(self): + return QNetworkCookieJar.allCookies(self) + + def setAllCookies(self, cookieList): + QNetworkCookieJar.setAllCookies(self, cookieList) + +class _WebkitRendererHelper(QObject): + """ + This helper class is doing the real work. It is required to + allow WebkitRenderer.render() to be called "asynchronously" + (but always from Qt's GUI thread). + """ + + def __init__(self, parent): + """ + Copies the properties from the parent (WebkitRenderer) object, + creates the required instances of QWebPage, QWebView and QMainWindow + and registers some Slots. + """ + QObject.__init__(self) + + # Copy properties from parent + for key,value in parent.__dict__.items(): + setattr(self,key,value) + + # Determine Proxy settings + proxy = QNetworkProxy(QNetworkProxy.NoProxy) + if 'http_proxy' in os.environ: + proxy_url = QUrl(os.environ['http_proxy']) + if unicode(proxy_url.scheme()).startswith('http'): + protocol = QNetworkProxy.HttpProxy + else: + protocol = QNetworkProxy.Socks5Proxy + + proxy = QNetworkProxy( + protocol, + proxy_url.host(), + proxy_url.port(), + proxy_url.userName(), + proxy_url.password() + ) + + # Create and connect required PyQt4 objects + self._page = CustomWebPage(logger=self.logger, ignore_alert=self.ignoreAlert, + ignore_confirm=self.ignoreConfirm, ignore_prompt=self.ignorePrompt, + interrupt_js=self.interruptJavaScript) + self._page.networkAccessManager().setProxy(proxy) + self._view = QWebView() + self._view.setPage(self._page) + self._window = QMainWindow() + self._window.setCentralWidget(self._view) + + # Import QWebSettings + for key, value in self.qWebSettings.iteritems(): + self._page.settings().setAttribute(key, value) + + # Connect required event listeners + self.connect(self._page, SIGNAL("loadFinished(bool)"), self._on_load_finished) + self.connect(self._page, SIGNAL("loadStarted()"), self._on_load_started) + self.connect(self._page.networkAccessManager(), SIGNAL("sslErrors(QNetworkReply *,const QList&)"), self._on_ssl_errors) + self.connect(self._page.networkAccessManager(), SIGNAL("finished(QNetworkReply *)"), self._on_each_reply) + + # The way we will use this, it seems to be unesseccary to have Scrollbars enabled + self._page.mainFrame().setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff) + self._page.mainFrame().setScrollBarPolicy(Qt.Vertical, Qt.ScrollBarAlwaysOff) + self._page.settings().setUserStyleSheetUrl(QUrl("data:text/css,html,body{overflow-y:hidden !important;}")) + + # Show this widget + self._window.show() + + def __del__(self): + """ + Clean up Qt4 objects. + """ + self._window.close() + del self._window + del self._view + del self._page + + def render(self, res): + """ + The real worker. Loads the page (_load_page) and awaits + the end of the given 'delay'. While it is waiting outstanding + QApplication events are processed. + After the given delay, the Window or Widget (depends + on the value of 'grabWholeWindow' is drawn into a QPixmap + and postprocessed (_post_process_image). + """ + self._load_page(res, self.width, self.height, self.timeout) + # Wait for end of timer. In this time, process + # other outstanding Qt events. + if self.wait > 0: + if self.logger: self.logger.debug("Waiting %d seconds " % self.wait) + waitToTime = time.time() + self.wait + while time.time() < waitToTime: + if QApplication.hasPendingEvents(): + QApplication.processEvents() + + if self.renderTransparentBackground: + # Another possible drawing solution + image = QImage(self._page.viewportSize(), QImage.Format_ARGB32) + image.fill(QColor(255,0,0,0).rgba()) + + # http://ariya.blogspot.com/2009/04/transparent-qwebview-and-qwebpage.html + palette = self._view.palette() + palette.setBrush(QPalette.Base, Qt.transparent) + self._page.setPalette(palette) + self._view.setAttribute(Qt.WA_OpaquePaintEvent, False) + + painter = QPainter(image) + painter.setBackgroundMode(Qt.TransparentMode) + self._page.mainFrame().render(painter) + painter.end() + else: + if self.grabWholeWindow: + # Note that this does not fully ensure that the + # window still has the focus when the screen is + # grabbed. This might result in a race condition. + self._view.activateWindow() + image = QPixmap.grabWindow(self._window.winId()) + else: + image = QPixmap.grabWidget(self._window) + + return self._post_process_image(image) + + def _load_page(self, res, width, height, timeout): + """ + This method implements the logic for retrieving and displaying + the requested page. + """ + + # This is an event-based application. So we have to wait until + # "loadFinished(bool)" raised. + cancelAt = time.time() + timeout + self.__loading = True + self.__loadingResult = False # Default + + # When "res" is of type tuple, it has two elements where the first + # element is the HTML code to render and the second element is a string + # setting the base URL for the interpreted HTML code. + # When resource is of type str or unicode, it is handled as URL which + # shal be loaded + if type(res) == tuple: + url = res[1] + else: + url = res + + if self.encodedUrl: + qtUrl = QUrl.fromEncoded(url) + else: + qtUrl = QUrl(url) + + # Set the required cookies, if any + self.cookieJar = CookieJar(self.cookies, qtUrl) + self._page.networkAccessManager().setCookieJar(self.cookieJar) + + # Load the page + if type(res) == tuple: + self._page.mainFrame().setHtml(res[0], qtUrl) # HTML, baseUrl + else: + self._page.mainFrame().load(qtUrl) + + while self.__loading: + if timeout > 0 and time.time() >= cancelAt: + raise RuntimeError("Request timed out on %s" % res) + while QApplication.hasPendingEvents() and self.__loading: + QCoreApplication.processEvents() + + if self.logger: self.logger.debug("Processing result") + + if self.__loading_result == False: + if self.logger: self.logger.warning("Failed to load %s" % res) + + # Set initial viewport (the size of the "window") + size = self._page.mainFrame().contentsSize() + if self.logger: self.logger.debug("contentsSize: %s", size) + if width > 0: + size.setWidth(width) + if height > 0: + size.setHeight(height) + + self._window.resize(size) + + def _post_process_image(self, qImage): + """ + If 'scaleToWidth' or 'scaleToHeight' are set to a value + greater than zero this method will scale the image + using the method defined in 'scaleRatio'. + """ + if self.scaleToWidth > 0 or self.scaleToHeight > 0: + # Scale this image + if self.scaleRatio == 'keep': + ratio = Qt.KeepAspectRatio + elif self.scaleRatio in ['expand', 'crop']: + ratio = Qt.KeepAspectRatioByExpanding + else: # 'ignore' + ratio = Qt.IgnoreAspectRatio + qImage = qImage.scaled(self.scaleToWidth, self.scaleToHeight, ratio, Qt.SmoothTransformation) + if self.scaleRatio == 'crop': + qImage = qImage.copy(0, 0, self.scaleToWidth, self.scaleToHeight) + return qImage + + def _on_each_reply(self,reply): + """ + Logs each requested uri + """ + self.logger.debug("Received %s" % (reply.url().toString())) + + # Eventhandler for "loadStarted()" signal + def _on_load_started(self): + """ + Slot that sets the '__loading' property to true + """ + if self.logger: self.logger.debug("loading started") + self.__loading = True + + # Eventhandler for "loadFinished(bool)" signal + def _on_load_finished(self, result): + """Slot that sets the '__loading' property to false and stores + the result code in '__loading_result'. + """ + if self.logger: self.logger.debug("loading finished with result %s", result) + self.__loading = False + self.__loading_result = result + + # Eventhandler for "sslErrors(QNetworkReply *,const QList&)" signal + def _on_ssl_errors(self, reply, errors): + """ + Slot that writes SSL warnings into the log but ignores them. + """ + for e in errors: + if self.logger: self.logger.warn("SSL: " + e.errorString()) + reply.ignoreSslErrors() + + +class CustomWebPage(QWebPage): + def __init__(self, **kwargs): + """ + Class Initializer + """ + super(CustomWebPage, self).__init__() + self.logger = kwargs.get('logger', None) + self.ignore_alert = kwargs.get('ignore_alert', True) + self.ignore_confirm = kwargs.get('ignore_confirm', True) + self.ignore_prompt = kwargs.get('ignore_prompt', True) + self.interrupt_js = kwargs.get('interrupt_js', True) + + def javaScriptAlert(self, frame, message): + if self.logger: self.logger.debug('Alert: %s', message) + if not self.ignore_alert: + return super(CustomWebPage, self).javaScriptAlert(frame, message) + + def javaScriptConfirm(self, frame, message): + if self.logger: self.logger.debug('Confirm: %s', message) + if not self.ignore_confirm: + return super(CustomWebPage, self).javaScriptConfirm(frame, message) + else: + return False + + def javaScriptPrompt(self, frame, message, default, result): + """ + This function is called whenever a JavaScript program running inside frame tries to prompt + the user for input. The program may provide an optional message, msg, as well as a default value + for the input in defaultValue. + If the prompt was cancelled by the user the implementation should return false; + otherwise the result should be written to result and true should be returned. + If the prompt was not cancelled by the user, the implementation should return true and + the result string must not be null. + """ + if self.logger: self.logger.debug('Prompt: %s (%s)' % (message, default)) + if not self.ignore_prompt: + return super(CustomWebPage, self).javaScriptPrompt(frame, message, default, result) + else: + return False + + def shouldInterruptJavaScript(self): + """ + This function is called when a JavaScript program is running for a long period of time. + If the user wanted to stop the JavaScript the implementation should return true; otherwise false. + """ + if self.logger: self.logger.debug("WebKit ask to interrupt JavaScript") + return self.interrupt_js \ No newline at end of file diff --git a/requirements/common.txt b/requirements/common.txt new file mode 100644 index 0000000..aea079a --- /dev/null +++ b/requirements/common.txt @@ -0,0 +1,18 @@ +gphoto2==1.4.1 +gpiozero==1.2.0 +Pillow==3.1.0 +pifacecommon==4.1.2 +pifacedigitalio==3.0.5 +pytz==2015.2 +supervisor==3.1.3 +hashids==1.1.0 +ticketrenderer==0.2.0 +figure-sdk==0.2.0 +peewee==2.8.1 +Flask==0.11.1 +psutil==4.3.0 +netifaces==0.10.4 +piexif==1.0.5 +RPi.GPIO==0.6.3 +https://github.com/Postcard/python-epson-printer/archive/v1.11.0.zip +https://github.com/Postcard/py-custom-printer/archive/v0.1.4.zip \ No newline at end of file diff --git a/requirements/prod.txt b/requirements/prod.txt new file mode 100644 index 0000000..c3899b0 --- /dev/null +++ b/requirements/prod.txt @@ -0,0 +1 @@ +-r common.txt \ No newline at end of file diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..3cbe158 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,3 @@ +-r common.txt +pytest==2.9.1 +pytest-mock==0.11.0 \ No newline at end of file diff --git a/resources/Canon1200D-config.text b/resources/Canon1200D-config.text new file mode 100644 index 0000000..a5d55da --- /dev/null +++ b/resources/Canon1200D-config.text @@ -0,0 +1,466 @@ +/main/actions/syncdatetimeutc +Label: Synchronize camera date and time with PC +Type: TOGGLE +Current: 0 +/main/actions/syncdatetime +Label: Synchronize camera date and time with PC +Type: TOGGLE +Current: 0 +/main/actions/uilock +Label: UI Lock +Type: TOGGLE +Current: 2 +/main/actions/autofocusdrive +Label: Drive Canon DSLR Autofocus +Type: TOGGLE +Current: 0 +/main/actions/manualfocusdrive +Label: Drive Canon DSLR Manual focus +Type: RADIO +Current: None +Choice: 0 Near 1 +Choice: 1 Near 2 +Choice: 2 Near 3 +Choice: 3 None +Choice: 4 Far 1 +Choice: 5 Far 2 +Choice: 6 Far 3 +/main/actions/cancelautofocus +Label: Cancel Canon DSLR Autofocus +Type: TOGGLE +Current: 0 +/main/actions/eoszoom +Label: Canon EOS Zoom +Type: TEXT +Current: 0 +/main/actions/eoszoomposition +Label: Canon EOS Zoom Position +Type: TEXT +Current: 0,0 +/main/actions/viewfinder +Label: Canon EOS Viewfinder +Type: TOGGLE +Current: 2 +/main/actions/eosremoterelease +Label: Canon EOS Remote Release +Type: RADIO +Current: None +Choice: 0 None +Choice: 1 Press Half +Choice: 2 Press Full +Choice: 3 Release Half +Choice: 4 Release Full +Choice: 5 Immediate +Choice: 6 Press 1 +Choice: 7 Press 2 +Choice: 8 Press 3 +Choice: 9 Release 1 +Choice: 10 Release 2 +Choice: 11 Release 3 +/main/actions/opcode +Label: PTP Opcode +Type: TEXT +Current: 0x1001,0xparam1,0xparam2 +/main/settings/datetimeutc +Label: Camera Date and Time +Type: DATE +Current: 1484759058 +Printable: Mer 18 jan 18:04:18 2017 +Help: Use 'now' as the current time when setting. + +/main/settings/datetime +Label: Camera Date and Time +Type: DATE +Current: 1484759058 +Printable: Mer 18 jan 18:04:18 2017 +Help: Use 'now' as the current time when setting. + +/main/settings/reviewtime +Label: Quick Review Time +Type: RADIO +Current: 2 seconds +Choice: 0 None +Choice: 1 2 seconds +Choice: 2 4 seconds +Choice: 3 8 seconds +Choice: 4 Hold +/main/settings/output +Label: Camera Output +Type: RADIO +Current: Off +Choice: 0 TFT +Choice: 1 PC +Choice: 2 TFT + PC +Choice: 3 Setting 4 +Choice: 4 Setting 5 +Choice: 5 Setting 6 +Choice: 6 Setting 7 +Choice: 7 Off +/main/settings/movierecordtarget +Label: Recording Destination +Type: RADIO +Current: None +Choice: 0 None +/main/settings/evfmode +Label: EVF Mode +Type: RADIO +Current: 1 +Choice: 0 1 +Choice: 1 0 +/main/settings/ownername +Label: Owner Name +Type: TEXT +Current: +/main/settings/artist +Label: Artist +Type: TEXT +Current: +/main/settings/copyright +Label: Copyright +Type: TEXT +Current: +/main/settings/customfuncex +Label: Custom Functions Ex +Type: TEXT +Current: bc,4,1,2c,3,101,1,0,103,1,0,10f,1,0,2,2c,3,201,1,0,202,1,0,203,1,0,3,14,1,50e,1,0,4,38,4,701,1,0,704,1,0,70e,1,0,811,1,0, +/main/settings/focusinfo +Label: Focus Info +Type: TEXT +Current: eosversion=0,size=5184x3456,size2=5184x3456,points={{0,743,117,181},{-839,393,172,129},{839,393,172,129},{-1394,0,172,129},{0,0,224,222},{1394,0,172,129},{-839,-393,172,129},{839,-393,172,129},{0,-743,117,181}},select={},unknown={ff010000ffff} +/main/settings/autopoweroff +Label: Auto Power Off +Type: TEXT +Current: 0 +/main/settings/depthoffield +Label: Depth of Field +Type: TEXT +Current: 0 +/main/settings/capturetarget +Label: Capture Target +Type: RADIO +Current: Memory card +Choice: 0 Internal RAM +Choice: 1 Memory card +/main/settings/capture +Label: Capture +Type: TOGGLE +Current: 0 +/main/status/serialnumber +Label: Serial Number +Type: TEXT +Current: None +/main/status/manufacturer +Label: Camera Manufacturer +Type: TEXT +Current: Canon Inc. +/main/status/cameramodel +Label: Camera Model +Type: TEXT +Current: Canon EOS 1200D +/main/status/deviceversion +Label: Device Version +Type: TEXT +Current: 3-1.0.1 +/main/status/vendorextension +Label: Vendor Extension +Type: TEXT +Current: None +/main/status/model +Label: Camera Model +Type: TEXT +Current: 2147484455 +/main/status/ptpversion +Label: PTP Version +Type: TEXT +Current: 256 +/main/status/batterylevel +Label: Battery Level +Type: TEXT +Current: 100% +/main/status/lensname +Label: Lens Name +Type: TEXT +Current: EF-S24mm f/2.8 STM +/main/status/eosserialnumber +Label: Serial Number +Type: TEXT +Current: 223074013233 +/main/status/shuttercounter +Label: Shutter Counter +Type: TEXT +Current: 197020 +/main/status/availableshots +Label: Available Shots +Type: TEXT +Current: 5319 +/main/imgsettings/imageformat +Label: Image Format +Type: RADIO +Current: Smaller JPEG +Choice: 0 Large Fine JPEG +Choice: 1 Large Normal JPEG +Choice: 2 Medium Fine JPEG +Choice: 3 Medium Normal JPEG +Choice: 4 Small Fine JPEG +Choice: 5 Small Normal JPEG +Choice: 6 Smaller JPEG +Choice: 7 Tiny JPEG +Choice: 8 RAW + Large Fine JPEG +Choice: 9 RAW +/main/imgsettings/imageformatsd +Label: Image Format SD +Type: RADIO +Current: Smaller JPEG +Choice: 0 Large Fine JPEG +Choice: 1 Large Normal JPEG +Choice: 2 Medium Fine JPEG +Choice: 3 Medium Normal JPEG +Choice: 4 Small Fine JPEG +Choice: 5 Small Normal JPEG +Choice: 6 Smaller JPEG +Choice: 7 Tiny JPEG +Choice: 8 RAW + Large Fine JPEG +Choice: 9 RAW +/main/imgsettings/iso +Label: ISO Speed +Type: RADIO +Current: 400 +Choice: 0 Auto +Choice: 1 100 +Choice: 2 200 +Choice: 3 400 +Choice: 4 800 +Choice: 5 1600 +Choice: 6 3200 +Choice: 7 6400 +/main/imgsettings/whitebalance +Label: WhiteBalance +Type: RADIO +Current: Auto +Choice: 0 Auto +Choice: 1 Daylight +Choice: 2 Shadow +Choice: 3 Cloudy +Choice: 4 Tungsten +Choice: 5 Fluorescent +Choice: 6 Flash +Choice: 7 Manual +/main/imgsettings/whitebalanceadjusta +Label: WhiteBalance Adjust A +Type: RADIO +Current: 0 +Choice: 0 -9 +Choice: 1 -8 +Choice: 2 -7 +Choice: 3 -6 +Choice: 4 -5 +Choice: 5 -4 +Choice: 6 -3 +Choice: 7 -2 +Choice: 8 -1 +Choice: 9 0 +Choice: 10 1 +Choice: 11 2 +Choice: 12 3 +Choice: 13 4 +Choice: 14 5 +Choice: 15 6 +Choice: 16 7 +Choice: 17 8 +Choice: 18 9 +/main/imgsettings/whitebalanceadjustb +Label: WhiteBalance Adjust B +Type: RADIO +Current: 0 +Choice: 0 -9 +Choice: 1 -8 +Choice: 2 -7 +Choice: 3 -6 +Choice: 4 -5 +Choice: 5 -4 +Choice: 6 -3 +Choice: 7 -2 +Choice: 8 -1 +Choice: 9 0 +Choice: 10 1 +Choice: 11 2 +Choice: 12 3 +Choice: 13 4 +Choice: 14 5 +Choice: 15 6 +Choice: 16 7 +Choice: 17 8 +Choice: 18 9 +/main/imgsettings/whitebalancexa +Label: WhiteBalance X A +Type: TEXT +Current: 0 +/main/imgsettings/whitebalancexb +Label: WhiteBalance X B +Type: TEXT +Current: 0 +/main/imgsettings/colorspace +Label: Color Space +Type: RADIO +Current: sRGB +Choice: 0 sRGB +Choice: 1 AdobeRGB +/main/capturesettings/focusmode +Label: Focus Mode +Type: RADIO +Current: Manual +Choice: 0 One Shot +Choice: 1 AI Focus +Choice: 2 AI Servo +Choice: 3 Manual +/main/capturesettings/autoexposuremode +Label: Canon Auto Exposure Mode +Type: RADIO +Current: Manual +Choice: 0 P +Choice: 1 TV +Choice: 2 AV +Choice: 3 Manual +Choice: 4 Bulb +Choice: 5 A_DEP +Choice: 6 DEP +Choice: 7 Custom +Choice: 8 Lock +Choice: 9 Green +Choice: 10 Night Portrait +Choice: 11 Sports +Choice: 12 Portrait +Choice: 13 Landscape +Choice: 14 Closeup +Choice: 15 Flash Off +/main/capturesettings/drivemode +Label: Drive Mode +Type: RADIO +Current: Single +Choice: 0 Single +Choice: 1 Continuous +Choice: 2 Timer 10 sec +Choice: 3 Timer 2 sec +Choice: 4 Unknown value 0007 +/main/capturesettings/picturestyle +Label: Picture Style +Type: RADIO +Current: Auto +Choice: 0 Auto +Choice: 1 Standard +Choice: 2 Portrait +Choice: 3 Landscape +Choice: 4 Neutral +Choice: 5 Faithful +Choice: 6 Monochrome +Choice: 7 User defined 1 +Choice: 8 User defined 2 +Choice: 9 User defined 3 +/main/capturesettings/aperture +Label: Aperture +Type: RADIO +Current: 2.8 +Choice: 0 2.8 +Choice: 1 3.2 +Choice: 2 3.5 +Choice: 3 4 +Choice: 4 4.5 +Choice: 5 5 +Choice: 6 5.6 +Choice: 7 6.3 +Choice: 8 7.1 +Choice: 9 8 +Choice: 10 9 +Choice: 11 10 +Choice: 12 11 +Choice: 13 13 +Choice: 14 14 +Choice: 15 16 +Choice: 16 18 +Choice: 17 20 +Choice: 18 22 +/main/capturesettings/shutterspeed +Label: Shutter Speed +Type: RADIO +Current: 1/200 +Choice: 0 bulb +Choice: 1 30 +Choice: 2 25 +Choice: 3 20 +Choice: 4 15 +Choice: 5 13 +Choice: 6 10 +Choice: 7 8 +Choice: 8 6 +Choice: 9 5 +Choice: 10 4 +Choice: 11 3.2 +Choice: 12 2.5 +Choice: 13 2 +Choice: 14 1.6 +Choice: 15 1.3 +Choice: 16 1 +Choice: 17 0.8 +Choice: 18 0.6 +Choice: 19 0.5 +Choice: 20 0.4 +Choice: 21 0.3 +Choice: 22 1/4 +Choice: 23 1/5 +Choice: 24 1/6 +Choice: 25 1/8 +Choice: 26 1/10 +Choice: 27 1/13 +Choice: 28 1/15 +Choice: 29 1/20 +Choice: 30 1/25 +Choice: 31 1/30 +Choice: 32 1/40 +Choice: 33 1/50 +Choice: 34 1/60 +Choice: 35 1/80 +Choice: 36 1/100 +Choice: 37 1/125 +Choice: 38 1/160 +Choice: 39 1/200 +/main/capturesettings/meteringmode +Label: Metering Mode +Type: RADIO +Current: Evaluative +Choice: 0 Evaluative +Choice: 1 Partial +Choice: 2 Center-weighted average +/main/capturesettings/bracketmode +Label: Bracket Mode +Type: TEXT +Current: 0 +/main/capturesettings/aeb +Label: Auto Exposure Bracketing +Type: RADIO +Current: off +Choice: 0 off +/main/other/d402 +Label: PTP Property 0xd402 +Type: TEXT +Current: Canon EOS 1200D +/main/other/d407 +Label: PTP Property 0xd407 +Type: TEXT +Current: 1 +/main/other/d406 +Label: PTP Property 0xd406 +Type: TEXT +Current: Unknown Initiator +/main/other/d303 +Label: PTP Property 0xd303 +Type: TEXT +Current: 1 +/main/other/5001 +Label: Battery Level +Type: MENU +Current: 100 +Choice: 0 100 +Choice: 1 0 +Choice: 2 75 +Choice: 3 0 +Choice: 4 50 diff --git a/resources/snapshot.jpg b/resources/snapshot.jpg deleted file mode 100644 index ec3e072..0000000 Binary files a/resources/snapshot.jpg and /dev/null differ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 36af104..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -python_files=test_figure.py diff --git a/start.sh b/start.sh index 499bac9..ad46a8e 100644 --- a/start.sh +++ b/start.sh @@ -2,53 +2,26 @@ echo 'Starting Figure app' -if [[ $DD_API_KEY ]]; -then - sed -i -e "s/^.*api_key:.*$/api_key: ${DD_API_KEY}/" ~/.datadog-agent/agent/datadog.conf - if [ -f /root/.datadog-agent/run/agent-supervisor.sock ] - then - unlink /root/.datadog-agent/run/agent-supervisor.sock - fi - # The agent needs to be executed with the current working directory set datadog-agent directory - cd /root/.datadog-agent - ./bin/agent start & -else - echo "You must set DD_API_KEY environment variable to run the Datadog Agent container" -fi - # Enable I2C. See http://docs.resin.io/#/pages/i2c-and-spi.md for more details modprobe i2c-dev -# Enable RTC -if [[ $RTC ]]; then - - i2cset -y 1 0x6f 0x08 0x47 - modprobe i2c:mcp7941x - echo mcp7941x 0x6f > /sys/class/i2c-dev/i2c-1/device/new_device - - wget -q --spider http://google.com - - if [ $? -eq 0 ]; then - echo "Device online, setting hardware clock from system" - hwclock --systohc - else - echo "Device offline, setting system clock from hardware clock" - hwclock --hctosys - fi -fi - # create data directories mkdir -p /data/static /data/media/tickets /data/media/images /data/media/pictures # Make sure $HOSTNAME is present in /etc/hosts grep -q "$HOSTNAME" /etc/hosts || echo "127.0.0.1 $HOSTNAME" >> /etc/hosts +mkdir -p /data/log && touch /data/log/figure.log && touch /data/log/wifi-connect.log + +# Start Xvfb +/etc/init.d/xvfb start # Start Wifi Access Point if WIFI_ON if [ "$WIFI_ON" = 1 ]; then - cd /usr/src/app && npm start & + export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/host/run/dbus/system_bus_socket + node /usr/src/app/resin-wifi-connect/src/app.js --clear=false >> /data/log/wifi-connect.log 2>&1 & fi if [ -f /var/run/supervisor.sock ] @@ -56,11 +29,6 @@ then unlink /var/run/supervisor.sock fi -# lock supervisor update by default -lockfile /data/resin-updates.lock - -mkdir -p /data/log && touch /data/log/figure.log - # mount RAM disk mkdir -p /mnt/ramdisk mount -t tmpfs -o size=2m tmpfs /mnt/ramdisk diff --git a/test_figure.py b/test_figure.py deleted file mode 100644 index 5d28420..0000000 --- a/test_figure.py +++ /dev/null @@ -1,1226 +0,0 @@ -# -*- coding: utf8 -*- - -import pytest -import time -from threading import Lock, Event as ThreadEvent -import pytz -from datetime import datetime -from os.path import join - -from mock import Mock, patch, create_autospec, call -from figure.error import BadRequestError, APIConnectionError -from PIL import Image as PILImage - -from figureraspbian import utils, settings -from figureraspbian.utils import timeit, download -from figureraspbian.devices.button import Button, PiFaceDigitalButton, EventThread, HoldThread -from figureraspbian.db import Photobooth, TicketTemplate, Place, Event, Code, Portrait, Text, Image, TextVariable, ImageVariable -from figureraspbian import db -from figureraspbian.photobooth import update, upload_portrait, upload_portraits, trigger -from figureraspbian.decorators import execute_if_not_busy -from figureraspbian.exceptions import DevicesBusy - - -class TestUtils: - - def test_url2name(self): - """ - url2name should extract file name in url - """ - name = utils.url2name('http://api.figuredevices.com/static/css/ticket.css') - assert name == 'ticket.css' - - @patch('figureraspbian.utils.logger.info') - def test_timeit(self, mock_info): - """ - timeit should log time spend in a function - """ - @timeit - def sleep(): - time.sleep(0.5) - return "wake up" - r = sleep() - assert r == "wake up" - assert mock_info.called - - def testpng2pos(self): - """ - png2pos should convert an image into pos data ready to be sent to the printer - """ - pos_data = utils.png2pos('test_ticket.jpg') - assert pos_data - - def test_get_filename(self): - filename = utils.get_file_name("CODES") - assert filename == 'Figure_N5rIARTnVC1ySp0.jpg' - - def test_pixels2cm(self): - """ - pixels2cm should convert image pixels into how many cm will actually be printed on the ticket - """ - cm = utils.pixels2cm(1098) - assert abs(cm - 14.5) < 0.1 - - def test_get_pure_black_and_white_ticket(self, mocker): - """ - it should convert PIL Image to '1' and return the path and the ticket length - """ - im = PILImage.open('test_ticket.jpg') - mock_image_open = mocker.patch.object(PILImage, 'open') - mock_image_open.return_value = im - ticket_path, length = utils.get_pure_black_and_white_ticket('') - assert ticket_path == '/Users/benoit/git/figure-raspbian/media/ticket.png' - assert length == 958 - - def test_execute_if_not_busy(self): - """ - it should execute function only if the lock is released - """ - lock = Lock() - mock_trigger = Mock() - - @execute_if_not_busy(lock) - def trigger(): - mock_trigger() - - trigger() - assert mock_trigger.call_count == 1 - assert not lock.locked() - - lock.acquire() - with pytest.raises(DevicesBusy): - trigger() - - assert mock_trigger.call_count == 1 - - def test_download(self, mocker): - """ it should download file if not present in local file system and return file path """ - mock_exists = mocker.patch('figureraspbian.utils.exists') - mock_exists.return_value = False - - mock_urllib = mocker.patch('figureraspbian.utils.urllib2') - mock_response = Mock() - mock_urllib.urlopen.return_value = mock_response - - mock_write_file = mocker.patch('figureraspbian.utils.write_file') - - path = download('https://figure-integration.s3.amazonaws.com/media/images/1467986947463.jpg', settings.IMAGE_ROOT) - - assert path == join(settings.IMAGE_ROOT, '1467986947463.jpg') - assert mock_urllib.urlopen.called - assert mock_response.read.called - assert mock_write_file.called - - def test_download_file_already_exists(self, mocker): - """ it should not download file if it already exists in the file system """ - mock_exists = mocker.patch('figureraspbian.utils.exists') - mock_exists.return_value = True - path = download('https://figure-integration.s3.amazonaws.com/media/images/1467986947463.jpg', settings.IMAGE_ROOT) - assert path == join(settings.IMAGE_ROOT, '1467986947463.jpg') - - -@pytest.fixture -def db_fixture(request): - db.erase() - db.init() - - def fin(): - db.close() - request.addfinalizer(fin) # destroy when session is finished - return db - - -class TestDatabase: - - def test_get_photobooth(self, db_fixture): - """ it should return the photobooth corresponding to RESIN_UUID""" - - ticket_template = TicketTemplate.create(html='', title='title', description='description', modified='2015-01-01T00:00:00Z') - sentiment = TextVariable.create(id='1', name='sentiment', ticket_template=ticket_template, mode='random') - Text.create(value="Un peu", variable=sentiment) - Text.create(value="beaucoup", variable=sentiment) - Text.create(value="à la folie", variable=sentiment) - logo = ImageVariable.create(id='2', name='logo', ticket_template=ticket_template, mode='random') - Image.create(path='/path/to/image1', variable=logo) - Image.create(path='/path/to/image2', variable=logo) - Image.create(path='/path/to/image3', ticket_template=ticket_template) - place = Place.create(id='1', name='Somewhere', timezone='Europe/Paris', modified='2015-01-01T00:00:00Z') - event = Event.create(id='1', name='Party', modified='2015-01-01T00:00:00Z') - db.update_photobooth(uuid='resin_uuid', id=1, place=place, event=event, ticket_template=ticket_template, - paper_level=100) - - photobooth = db.get_photobooth() - assert photobooth.uuid == 'resin_uuid' - assert photobooth.place.name == 'Somewhere' - assert photobooth.event.name == 'Party' - ticket_template = photobooth.ticket_template - assert ticket_template.html == '' - assert ticket_template.title == 'title' - assert ticket_template.description == 'description' - assert len(ticket_template.text_variables) == 1 - assert ticket_template.text_variables[0].name == 'sentiment' - assert len(ticket_template.text_variables[0].items) == 3 - assert len(ticket_template.image_variables) == 1 - assert ticket_template.image_variables[0].name == 'logo' - assert len(ticket_template.image_variables[0].items) == 2 - assert len(ticket_template.images) == 1 - - def test_ticket_template_serializer(self, db_fixture): - ticket_template = TicketTemplate.create(id=1, html='', title='title', description='description', modified='2015-01-01T00:00:00Z') - sentiment = TextVariable.create(id='1', name='sentiment', ticket_template=ticket_template, mode='random') - Text.create(value="Un peu", variable=sentiment) - Text.create(value="beaucoup", variable=sentiment) - Text.create(value="à la folie", variable=sentiment) - logo = ImageVariable.create(id='2', name='logo', ticket_template=ticket_template, mode='random') - Image.create(path='/path/to/image1', variable=logo) - Image.create(path='/path/to/image2', variable=logo) - Image.create(path='/path/to/image3', ticket_template=ticket_template) - expected = { - 'description': 'description', - 'title': 'title', - 'image_variables': [ - { - 'items': [ - {'id': 1, 'name': u'image1'}, - {'id': 2, 'name': u'image2'} - ], - 'mode': u'random', - 'id': 2, - 'name': u'logo' - } - ], - 'modified': '2015-01-01T00:00:00Z', - 'html': '', - 'images': [ - {'id': 3, 'name': u'image3'} - ], - 'id': 1, - 'text_variables': [ - { - 'items': [ - {'text': u'Un peu', 'id': 1}, - {'text': u'beaucoup', 'id': 2}, - {'text': u'\xe0 la folie', 'id': 3} - ], - 'mode': u'random', - 'id': 1, - 'name': u'sentiment'} - ] - } - assert ticket_template.serialize() == expected - - - def test_get_code(self, db_fixture): - """ it should return a code and delete it from the database """ - codes = ['AAAAA', 'BBBBB', 'CCCCC', 'DDDDD'] - for code in codes: - Code.create(value=code) - code = db.get_code() - assert code == 'AAAAA' - assert Code.select() == ['BBBBB', 'CCCCC', 'DDDDD'] - - def test_get_portraits_to_be_uploaded(self, db_fixture): - """ it should return the list of portraits that have not been marked as uploaded""" - data_source = [ - { - 'code': 'AAAAA', - 'taken': datetime.now(pytz.timezone(settings.DEFAULT_TIMEZONE)), - 'place_id': '1', - 'event_id': '1', - 'photobooth_id': '1', - 'ticket': '/path/to/ticket1', - 'picture': '/path/to/picture1', - 'uploaded': False - }, - { - 'code': 'BBBBB', - 'taken': datetime.now(pytz.timezone(settings.DEFAULT_TIMEZONE)), - 'place_id': '1', - 'event_id': '1', - 'photobooth_id': '1', - 'ticket': '/path/to/ticket2', - 'picture': '/path/to/picture2', - 'uploaded': True - } - ] - - for data_dict in data_source: - Portrait.create(**data_dict) - - portraits_to_be_uploaded = db.get_portraits_to_be_uploaded() - assert len(portraits_to_be_uploaded) == 1 - assert portraits_to_be_uploaded[0].code == 'AAAAA' - - def test_get_portrait_to_be_uploaded(self, db_fixture): - """ it should return first portrait to be uploaded or None """ - data_source = [ - { - 'code': 'AAAAA', - 'taken': datetime.now(pytz.timezone(settings.DEFAULT_TIMEZONE)), - 'place_id': '1', - 'event_id': '1', - 'photobooth_id': '1', - 'ticket': '/path/to/ticket1', - 'picture': '/path/to/picture1', - 'uploaded': False - }, - { - 'code': 'BBBBB', - 'taken': datetime.now(pytz.timezone(settings.DEFAULT_TIMEZONE)), - 'place_id': '1', - 'event_id': '1', - 'photobooth_id': '1', - 'ticket': '/path/to/ticket2', - 'picture': '/path/to/picture2', - 'uploaded': False - } - ] - - for data_dict in data_source: - Portrait.create(**data_dict) - - p1 = db.get_portrait_to_be_uploaded() - assert p1.code == 'AAAAA' - assert p1.ticket == '/path/to/ticket1' - assert p1.picture == '/path/to/picture1' - p1.uploaded = True - p1.save() - p2 = db.get_portrait_to_be_uploaded() - assert p2.code == 'BBBBB' - assert p2.ticket == '/path/to/ticket2' - assert p2.picture == '/path/to/picture2' - p2.uploaded = True - p2.save() - assert not db.get_portrait_to_be_uploaded() - - def test_upload_portraits(self, db_fixture, mocker): - - data_source = [ - { - 'code': 'AAAAA', - 'taken': datetime.now(pytz.timezone(settings.DEFAULT_TIMEZONE)), - 'place_id': '1', - 'event_id': '1', - 'photobooth_id': '1', - 'ticket': '/path/to/ticket1', - 'picture': '/path/to/picture1', - 'uploaded': False - }, - { - 'code': 'BBBBB', - 'taken': datetime.now(pytz.timezone(settings.DEFAULT_TIMEZONE)), - 'place_id': '1', - 'event_id': '1', - 'photobooth_id': '1', - 'ticket': '/path/to/ticket2', - 'picture': '/path/to/picture2', - 'uploaded': False - } - ] - - for data_dict in data_source: - Portrait.create(**data_dict) - - mock_figure = mocker.patch('figureraspbian.photobooth.figure') - mock_read_file = mocker.patch('figureraspbian.photobooth.read_file') - upload_portraits() - - assert mock_read_file.call_args_list == [ - call(u'/path/to/picture1'), - call(u'/path/to/ticket1'), - call(u'/path/to/picture2'), - call(u'/path/to/ticket2')] - - - def test_update_or_create_text(self, db_fixture): - """ - it should create or update a text instance - """ - assert Text.select().count() == 0 - text = {'id': 1, 'text': 'some text'} - db.update_or_create_text(text) - instance = Text.get(Text.id == 1) - assert Text.select().count() == 1 - assert instance.value == 'some text' - text = {'id': 1, 'text': 'some longer text'} - db.update_or_create_text(text) - instance = Text.get(Text.id == 1) - assert Text.select().count() == 1 - assert instance.value == 'some longer text' - - def test_update_or_create_text_variable(self, db_fixture): - """ - it should update or create a text variable instance - """ - assert TextVariable.select().count() == 0 - data = { - "mode": "random", - "id": 1, - "name": "sentiment", - "items": [{ - "id": 1, - "text": "Un peu", - "variable": 1 - }, { - "id": 2, - "text": "Beaucoup", - "variable": 1 - }] - } - db.update_or_create_text_variable(data) - assert TextVariable.select().count() == 1 - text_variable = TextVariable.get(TextVariable.id == 1) - assert text_variable.name == 'sentiment' - text_variable.items.count() == 2 - data['name'] = "towns" - data['mode'] = "sequential" - data['items'] = [{ - "id": 3, - "text": "Paris", - "variable": 1 - }, { - "id": 4, - "text": "Londres", - "variable": 1 - }] - Text.create(id=5, value='some other text') - db.update_or_create_text_variable(data) - text_variable = TextVariable.get(TextVariable.id == 1) - assert text_variable.name == 'towns' - assert text_variable.mode == 'sequential' - assert text_variable.items.count() == 2 - items = text_variable.items - assert len(items) == 2 - assert items[0].id == 3 - assert items[1].id == 4 - - def test_update_or_create_image(self, mocker, db_fixture): - """ it should update or create an image """ - assert Image.select().count() == 0 - data = { - 'id': 1, - 'image': 'https://path/to/image.jpg', - 'name': 'image.jpg' - } - download_mock = mocker.patch('figureraspbian.db.download') - download_mock.return_value = '/path/to/image.jpg' - db.update_or_create_image(data) - image = Image.get(Image.id == 1) - assert download_mock.call_count == 1 - assert image.path == '/path/to/image.jpg' - db.update_or_create_image(data) - assert download_mock.call_count == 1 - data = { - 'id': 1, - 'image': 'https://path/to/image2.jpg', - 'name': 'image2.jpg' - } - download_mock.return_value = '/path/to/image2.jpg' - db.update_or_create_image(data) - assert download_mock.call_count == 2 - image = Image.get(Image.id == 1) - assert image.path == '/path/to/image2.jpg' - - def test_update_or_create_image_variable(self, mocker, db_fixture): - assert ImageVariable.select().count() == 0 - data = { - "mode": "random", - "id": 1, - "name": "landscape", - "items": [ - { - "id": 1, - "image": "https://path/to/image.jpg", - "name": "image.jpg", - } - ] - } - download_mock = mocker.patch('figureraspbian.db.download') - download_mock.return_value = '/path/to/image.jpg' - db.update_or_create_image_variable(data) - image_variable = ImageVariable.get(ImageVariable.id == 1) - assert image_variable.name == 'landscape' - assert len(image_variable.items) == 1 - assert download_mock.call_count == 1 - assert(image_variable.items[0].id == 1) - data['mode'] = 'sequential' - data['name'] = 'planets' - data['items'] = [ - { - "id": 2, - "image": "https://path/to/image2.jpg", - "name": "image2.jpg" - } - ] - Image.create(id=3, path='/path/to/image3.jpg') - db.update_or_create_image_variable(data) - download_mock.return_value = '/path/to/image2.jpg' - image_variable = ImageVariable.get(ImageVariable.id == 1) - assert Image.select().count() == 2 - assert image_variable.mode == 'sequential' - assert image_variable.name == 'planets' - assert len(image_variable.items) - assert image_variable.items[0].id == 2 - assert download_mock.call_count == 2 - - def test_update_or_create_ticket_template(self, mocker, db_fixture): - - data = { - "id": 1, - "modified": "2015-01-01T00:00:00Z", - "html": "
", - "title": "title", - "description": "description", - "text_variables": [ - { - "mode": "random", - "id": 1, - "name": "sentiment", - "items": [{ - "id": 1, - "text": "Un peu", - "variable": 1 - }, { - "id": 2, - "text": "Beaucoup", - "variable": 1 - }] - } - ], - "image_variables": [ - { - "mode": "random", - "id": 2, - "name": "landscape", - "items": [ - { - "id": 2, - "image": "http://image2.png", - "name": "image2", - "variable": 2 - } - ] - } - ], - "images": [{ - "id": 1, - "image": "http://image1.png", - "name": "image1.png", - "variable": None - }] - } - download_mock = mocker.patch('figureraspbian.db.download') - download_mock.return_value = '/path/to/image.jpg' - db.update_or_create_ticket_template(data) - ticket_template = TicketTemplate.get(TicketTemplate.id == 1) - assert ticket_template.html == '
' - assert ticket_template.title == 'title' - assert ticket_template.description == 'description' - assert len(ticket_template.text_variables) == 1 - assert len(ticket_template.text_variables[0].items) == 2 - assert len(ticket_template.images) == 1 - assert len(ticket_template.image_variables) == 1 - assert len(ticket_template.image_variables[0].items) == 1 - data['modified'] = '2015-01-02T00:00:00Z' - data['html'] = '
some text
' - data['title'] = 'title2' - data['description'] = 'description2' - db.update_or_create_ticket_template(data) - ticket_template = TicketTemplate.get(TicketTemplate.id == 1) - assert ticket_template.modified == '2015-01-02T00:00:00Z' - assert ticket_template.html == '
some text
' - assert ticket_template.title == 'title2' - assert ticket_template.description == 'description2' - - def test_update_photobooth(self, db_fixture): - place = Place.create(name='somewhere', tz='Europe/Paris', modified='2015-01-02T00:00:00Z') - event = Event.create(name='party', modified='2015-01-02T00:00:00Z') - db.update_photobooth(place=place, event=event, id=2, counter=1) - photobooth = db.get_photobooth() - assert photobooth.counter == 1 - assert photobooth.place == place - assert photobooth.event == event - assert photobooth.id == 2 - - def test_increment_counter(self, db_fixture): - """ - it should increment the photobooth photo counter - """ - photobooth = db.get_photobooth() - assert photobooth.counter == 0 - db.increment_counter() - photobooth = db.get_photobooth() - assert photobooth.counter == 1 - - def test_bulk_insert_codes(self, db_fixture): - """ - it should create codes from an array of codes - """ - codes = ['AAAAA'] * 1001 - db.bulk_insert_codes(codes) - assert Code.select().count() == 1001 - - def test_should_claim_codes(self, db_fixture): - """ - it should return True if and only if number of codes is below 1000 - """ - codes = ['AAAAA'] * 1001 - db.bulk_insert_codes(codes) - assert db.should_claim_code() == False - db.get_code() - db.get_code() - assert db.should_claim_code() == True - - def test_delete_ticket_template(self, db_fixture): - """ - it should not cascade deletion to Photobooth instance - """ - tt = TicketTemplate.create(html='html', title='title', description='description', modified='2015-01-02T00:00:00Z') - photobooth = db.get_photobooth() - photobooth.ticket_template = tt - photobooth.save() - db.delete(photobooth.ticket_template) - db.update_photobooth(ticket_template=None) - photobooth = db.get_photobooth() - assert photobooth is not None - assert photobooth.ticket_template is None - assert TicketTemplate.select().count() == 0 - - def test_delete_place(self, db_fixture): - """ - it should not cascade deletion to Photobooth instance - """ - place = Place.create(name='Le Pop Up du Label', modified='2015-01-02T00:00:00Z') - photobooth = db.get_photobooth() - photobooth.place = place - photobooth.save() - db.delete(photobooth.place) - db.update_photobooth(place=None) - photobooth = db.get_photobooth() - assert photobooth is not None - assert photobooth.place is None - assert Place.select().count() == 0 - - def test_delete_event(self, db_fixture): - """ - it should not cascade deletion to Photobooth instance - """ - event = Event.create(name='La wedding #5', modified='2015-01-02T00:00:00Z') - photobooth = db.get_photobooth() - photobooth.event = event - photobooth.save() - db.delete(photobooth.event) - db.update_photobooth(event=None) - photobooth = db.get_photobooth() - assert photobooth is not None - assert photobooth.event is None - assert Event.select().count() == 0 - - def test_update_paper_level(self, db_fixture): - pass - - -class TestPhotobooth: - - def test_update_on_the_first_time(self, mocker): - """ it should create place, event and ticket template and associate it to the photobooth instance """ - mock_db = mocker.patch('figureraspbian.photobooth.db') - mock_photobooth = create_autospec(Photobooth) - mock_photobooth.place = None - mock_photobooth.event = None - mock_photobooth.ticket_template = None - mock_db.get_photobooth.return_value = mock_photobooth - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Photobooth') - mock_api.get.return_value = { - 'id': 2, - 'serial_number': 'FIG 00001', - 'uuid': settings.RESIN_UUID, - 'place': { - 'id': 1, - 'modified': '2016-06-07T07:50:41Z', - 'name': 'Le Pop up du Label', - 'tz': 'Europe/Paris', - }, - 'event': { - 'id': 1, - 'modified': '2016-06-07T07:50:41Z', - 'name': 'La wedding #5' - }, - 'ticket_template': { - 'id': 1, - 'modified': '2016-06-07T07:50:41Z', - 'html': 'html', - 'title': 'title', - 'description': 'description', - 'text_variables': [ - { - 'id': '1', - 'mode': 'random', - 'name': 'textvariable', - 'items': [ - {'id': '1', 'text': 'text'} - ] - } - ], - 'image_variables': [ - { - 'id': '2', - 'mode': 'random', - 'name': 'imagevariable', - 'items': [ - { - 'id': '1', - 'image': 'image' - } - ] - } - ], - 'images': [ - { - 'id': '2', - 'image': 'image2' - } - ] - } - } - update() - assert mock_db.create_place.called - assert mock_db.create_event.called - assert mock_db.update_or_create_ticket_template.called - assert mock_db.update_photobooth.call_count == 4 - assert mock_db.update_photobooth.call_args_list[0] == call(id=2, serial_number='FIG 00001') - - - - def test_update_reset(self, mocker): - """ it should delete place, event and ticket template and set corresponding value to None on photobooth """ - mock_db = mocker.patch('figureraspbian.photobooth.db') - mock_photobooth = create_autospec(Photobooth) - mock_place = create_autospec(Place) - mock_event = create_autospec(Event) - mock_ticket_template = create_autospec(TicketTemplate) - mock_photobooth.place = mock_place - mock_photobooth.event = mock_event - mock_photobooth.ticket_template = mock_ticket_template - mock_db.get_photobooth.return_value = mock_photobooth - mock_api = mocker.patch('figureraspbian.photobooth.figure.Photobooth') - mock_api.get.return_value = { - 'id': 2, - 'serial_number': 'FIG 00002', - 'uuid': settings.RESIN_UUID, - 'place': None, - 'event': None, - 'ticket_template': None - } - update() - assert mock_db.delete.call_args_list == [call(mock_place), call(mock_event), call(mock_ticket_template)] - assert mock_db.update_photobooth.call_args_list == [ - call(id=2, serial_number='FIG 00002'), - call(place=None), - call(event=None), - call(ticket_template=None)] - - def test_different_id(self, mocker): - """ - it should create new instances of place, event and ticket_template, associate it to the photobooth instance - and delete previous instances of placce, event and ticket_template - """ - mock_db = mocker.patch('figureraspbian.photobooth.db') - mock_photobooth = create_autospec(Photobooth) - mock_place = create_autospec(Place) - mock_place.id = 1 - mock_event = create_autospec(Event) - mock_event.id = 1 - mock_ticket_template = create_autospec(TicketTemplate) - mock_ticket_template.id = 1 - mock_photobooth.place = mock_place - mock_photobooth.event = mock_event - mock_photobooth.ticket_template = mock_ticket_template - mock_db.get_photobooth.return_value = mock_photobooth - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Photobooth') - mock_api.get.return_value = { - 'id': 2, - 'uuid': settings.RESIN_UUID, - 'serial_number': 'FIG 00002', - 'place': { - 'id': 2, - }, - 'event': { - 'id': 2, - }, - 'ticket_template': { - 'id': 2, - } - } - update() - assert mock_db.delete.call_args_list == [call(mock_place), call(mock_event), call(mock_ticket_template)] - assert mock_db.create_place.call_args_list == [call({'id': 2})] - assert mock_db.create_event.call_args_list == [call({'id': 2})] - assert mock_db.update_or_create_ticket_template.call_args_list == [call({'id': 2})] - assert mock_db.update_photobooth.call_count == 4 - - def test_same_id_but_modified(self, mocker): - """ - it should update place, event, and ticket template - """ - mock_db = mocker.patch('figureraspbian.photobooth.db') - mock_photobooth = create_autospec(Photobooth) - mock_place = create_autospec(Place) - mock_place.id = 1 - mock_place.modified = '2016-06-01T00:00:00Z' - mock_event = create_autospec(Event) - mock_event.id = 1 - mock_event.modified = '2016-06-01T00:00:00Z' - mock_ticket_template = create_autospec(TicketTemplate) - mock_ticket_template.id = 1 - mock_ticket_template.modified = '2016-06-01T00:00:00Z' - mock_photobooth.place = mock_place - mock_photobooth.event = mock_event - mock_photobooth.ticket_template = mock_ticket_template - mock_db.get_photobooth.return_value = mock_photobooth - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Photobooth') - mock_api.get.return_value = { - 'id': 1, - 'uuid': settings.RESIN_UUID, - 'place': { - 'id': 1, - 'modified': '2016-06-02T00:00:00Z' - }, - 'event': { - 'id': 1, - 'modified': '2016-06-02T00:00:00Z' - }, - 'ticket_template': { - 'id': 1, - 'modified': '2016-06-02T00:00:00Z' - } - } - update() - assert mock_db.update_place.callled - assert mock_db.update_event.called - assert mock_db.update_or_create_ticket_template.called - - def test_upload_portrait_raise_exception(self, mocker): - """ it should save portrait to local db and filesystem if any error occurs during the upload""" - mock_api = mocker.patch('figureraspbian.photobooth.figure.Portrait') - mock_api.create.side_effect = Exception - mock_db = mocker.patch('figureraspbian.photobooth.db') - mock_write_file = mocker.patch('figureraspbian.photobooth.write_file') - portrait = { - 'picture': 'base64encodedfile', - 'ticket': 'base64encodedfile', - 'taken': 'somedate', - 'place': '1', - 'event': '1', - 'photobooth': '1', - 'code': 'AAAAA', - 'filename': 'Figure_dqidqid.jpg' - } - upload_portrait(portrait) - assert mock_write_file.call_args_list == [ - call('base64encodedfile', join(settings.PICTURE_ROOT, 'Figure_dqidqid.jpg')), - call('base64encodedfile', join(settings.TICKET_ROOT, 'Figure_dqidqid.jpg'))] - assert mock_db.create_portrait.call_args_list == [call({ - 'picture': join(settings.PICTURE_ROOT, 'Figure_dqidqid.jpg'), - 'code': 'AAAAA', - 'place': '1', - 'photobooth': '1', - 'taken': 'somedate', - 'ticket': join(settings.TICKET_ROOT, 'Figure_dqidqid.jpg'), - 'event': '1'})] - assert mock_api.create.call_args_list == [call( - files={'ticket': ('Figure_dqidqid.jpg', 'base64encodedfile'), 'picture_color': ('Figure_dqidqid.jpg', 'base64encodedfile')}, - data={'taken': 'somedate', 'code': 'AAAAA', 'place': '1', 'event': '1', 'photobooth': '1'})] - - def test_upload_portraits(self, mocker): - """ it should upload all non uploaded portraits and set uploaded to False""" - mock_db = mocker.patch('figureraspbian.photobooth.db') - portrait1 = create_autospec(Portrait) - portrait2 = create_autospec(Portrait) - portrait3 = create_autospec(Portrait) - - portrait1.id = 1 - portrait1.code = 'AAAAA' - portrait1.taken = '2016-06-02T00:00:00Z' - portrait1.place_id = '1' - portrait1.event_id = '1' - portrait1.photobooth_id = '1' - portrait1.picture = '/path/to/picture' - portrait1.ticket = '/path/to/ticket' - portrait1.uploaded = False - - portrait2.id = 2 - portrait2.code = 'BBBBB' - portrait2.taken = '2016-06-02T00:00:00Z' - portrait2.place_id = '1' - portrait2.event_id = '1' - portrait2.photobooth_id = '1' - portrait2.picture = '/path/to/picture' - portrait2.ticket = '/path/to/ticket' - portrait2.uploaded = False - - portrait3.id = 3 - portrait3.code = 'CCCCC' - portrait3.taken = '2016-06-02T00:00:00Z' - portrait3.place_id = '1' - portrait3.event_id = '1' - portrait3.photobooth_id = '1' - portrait3.picture = '/path/to/picture' - portrait3.ticket = '/path/to/ticket' - portrait3.uploaded = False - - mock_db.get_portrait_to_be_uploaded.side_effect = [portrait1, portrait2, portrait3, None] - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Portrait') - - mock_read_file = mocker.patch('figureraspbian.photobooth.read_file') - mock_read_file.return_value = 'file content' - - upload_portraits() - - assert mock_api.create.call_args_list == [ - call(files={'ticket': 'file content', 'picture_color': 'file content'}, - data={'taken': '2016-06-02T00:00:00Z', 'code': 'AAAAA', 'place': '1', 'event': '1', 'photobooth': '1'}), - call(files={'ticket': 'file content', 'picture_color': 'file content'}, - data={'taken': '2016-06-02T00:00:00Z', 'code': 'BBBBB', 'place': '1', 'event': '1', 'photobooth': '1'}), - call(files={'ticket': 'file content', 'picture_color': 'file content'}, - data={'taken': '2016-06-02T00:00:00Z', 'code': 'CCCCC', 'place': '1', 'event': '1', 'photobooth': '1'}) - ] - - assert mock_db.update_portrait.call_args_list == [ - call(1, uploaded=True), - call(2, uploaded=True), - call(3, uploaded=True) - ] - - def test_upload_portraits_raise_unknown_exception(self, mocker): - - mock_db = mocker.patch('figureraspbian.photobooth.db') - portrait1 = create_autospec(Portrait) - portrait2 = create_autospec(Portrait) - portrait3 = create_autospec(Portrait) - - portrait1.id = 1 - portrait1.code = 'AAAAA' - portrait1.taken = '2016-06-02T00:00:00Z' - portrait1.place_id = '1' - portrait1.event_id = '1' - portrait1.photobooth_id = '1' - portrait1.picture = '/path/to/picture' - portrait1.ticket = '/path/to/ticket' - portrait1.uploaded = False - - portrait2.id = 2 - portrait2.code = 'BBBBB' - portrait2.taken = '2016-06-02T00:00:00Z' - portrait2.place_id = '1' - portrait2.event_id = '1' - portrait2.photobooth_id = '1' - portrait2.picture = '/path/to/picture' - portrait2.ticket = '/path/to/ticket' - portrait2.uploaded = False - - portrait3.id = 3 - portrait3.code = 'CCCCC' - portrait3.taken = '2016-06-02T00:00:00Z' - portrait3.place_id = '1' - portrait3.event_id = '1' - portrait3.photobooth_id = '1' - portrait3.picture = '/path/to/picture' - portrait3.ticket = '/path/to/ticket' - portrait3.uploaded = False - - mock_db.get_portrait_to_be_uploaded.side_effect = [portrait1, portrait2, portrait3, None] - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Portrait') - mock_api.create.side_effect = Exception - - mock_read_file = mocker.patch('figureraspbian.photobooth.read_file') - mock_read_file.return_value = 'file content' - - upload_portraits() - - assert mock_db.get_portrait_to_be_uploaded.call_count == 1 - assert mock_api.create.call_count == 1 - assert mock_db.delete.call_count == 0 - - def test_upload_portrait_raise_BadRequest(self, mocker): - - mock_db = mocker.patch('figureraspbian.photobooth.db') - portrait1 = create_autospec(Portrait) - portrait2 = create_autospec(Portrait) - portrait3 = create_autospec(Portrait) - - portrait1.id = 1 - portrait1.code = 'AAAAA' - portrait1.taken = '2016-06-02T00:00:00Z' - portrait1.place_id = '1' - portrait1.event_id = '1' - portrait1.photobooth_id = '1' - portrait1.picture = '/path/to/picture' - portrait1.ticket = '/path/to/ticket' - portrait1.uploaded = False - - portrait2.id = 2 - portrait2.code = 'BBBBB' - portrait2.taken = '2016-06-02T00:00:00Z' - portrait2.place_id = '1' - portrait2.event_id = '1' - portrait2.photobooth_id = '1' - portrait2.picture = '/path/to/picture' - portrait2.ticket = '/path/to/ticket' - portrait2.uploaded = False - - portrait3.id = 3 - portrait3.code = 'CCCCC' - portrait3.taken = '2016-06-02T00:00:00Z' - portrait3.place_id = '1' - portrait3.event_id = '1' - portrait3.photobooth_id = '1' - portrait3.picture = '/path/to/picture' - portrait3.ticket = '/path/to/ticket' - portrait3.uploaded = False - - mock_db.get_portrait_to_be_uploaded.side_effect = [portrait1, portrait2, portrait3, None] - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Portrait') - mock_api.create.side_effect = BadRequestError - - mock_read_file = mocker.patch('figureraspbian.photobooth.read_file') - mock_read_file.return_value = 'file content' - - upload_portraits() - - assert mock_db.get_portrait_to_be_uploaded.call_count == 4 - assert mock_api.create.call_count == 3 - assert mock_db.delete.call_count == 3 - - def test_upload_portraits_raise_ConnectionError(self, mocker): - - mock_db = mocker.patch('figureraspbian.photobooth.db') - portrait1 = create_autospec(Portrait) - portrait2 = create_autospec(Portrait) - portrait3 = create_autospec(Portrait) - - portrait1.id = 1 - portrait1.code = 'AAAAA' - portrait1.taken = '2016-06-02T00:00:00Z' - portrait1.place_id = '1' - portrait1.event_id = '1' - portrait1.photobooth_id = '1' - portrait1.picture = '/path/to/picture' - portrait1.ticket = '/path/to/ticket' - portrait1.uploaded = False - - portrait2.id = 2 - portrait2.code = 'BBBBB' - portrait2.taken = '2016-06-02T00:00:00Z' - portrait2.place_id = '1' - portrait2.event_id = '1' - portrait2.photobooth_id = '1' - portrait2.picture = '/path/to/picture' - portrait2.ticket = '/path/to/ticket' - portrait2.uploaded = False - - portrait3.id = 3 - portrait3.code = 'CCCCC' - portrait3.taken = '2016-06-02T00:00:00Z' - portrait3.place_id = '1' - portrait3.event_id = '1' - portrait3.photobooth_id = '1' - portrait3.picture = '/path/to/picture' - portrait3.ticket = '/path/to/ticket' - portrait3.uploaded = False - - mock_db.get_portrait_to_be_uploaded.side_effect = [portrait1, portrait2, portrait3, None] - - mock_api = mocker.patch('figureraspbian.photobooth.figure.Portrait') - mock_api.create.side_effect = APIConnectionError - - mock_read_file = mocker.patch('figureraspbian.photobooth.read_file') - mock_read_file.return_value = 'file content' - - upload_portraits() - - assert mock_db.get_portrait_to_be_uploaded.call_count == 1 - assert mock_api.create.call_count == 1 - assert mock_db.delete.call_count == 0 - - @pytest.mark.skip(reason="need an API server running on localhost") - def test_upload_portrait_no_mock(self): - - picture = PILImage.open('test_snapshot.jpg') - ticket = PILImage.open('test_ticket.jpg') - - import cStringIO - buf1 = cStringIO.StringIO() - picture.save(buf1, "JPEG") - picture_io = buf1.getvalue() - - buf2 = cStringIO.StringIO() - ticket.save(buf2, "JPEG") - ticket_io = buf2.getvalue() - - portrait = { - 'picture': picture_io, - 'ticket': ticket_io, - 'taken': datetime.now(pytz.timezone('Europe/Paris')), - 'place': 1, - 'event': None, - 'photobooth': 1, - 'code': 'JUFD0', - 'filename': 'Figure_JHUGTTX.jpg' - } - - upload_portrait(portrait) - - - def test_trigger(self, mocker): - """it should take a picture, print a ticket and send data to the server""" - - mock_camera = mocker.patch('figureraspbian.photobooth.camera') - mock_camera.capture.return_value = PILImage.open('test_snapshot.jpg') - mock_printer = mocker.patch('figureraspbian.photobooth.printer') - - mock_db = mocker.patch('figureraspbian.photobooth.db') - - mock_ticket_template = create_autospec(TicketTemplate) - serialized = { - 'description': 'description', - 'title': 'title', - 'image_variables': [], - 'modified': '2015-01-01T00:00:00Z', - 'html': '', - 'images': [], - 'id': 1, - 'text_variables': [] - } - mock_ticket_template.serialize.return_value = serialized - - mock_place = create_autospec(Place) - mock_place.tz = 'Europe/Paris' - mock_place.id = 1 - - mock_event = create_autospec(Event) - mock_event.id = 1 - - mock_photobooth = create_autospec(Photobooth) - mock_photobooth.id = 1 - mock_photobooth.ticket_template = mock_ticket_template - mock_photobooth.counter = 0 - mock_photobooth.place = mock_place - mock_photobooth.event = mock_event - - mock_db.get_photobooth.return_value = mock_photobooth - - mock_db.get_code.return_value = 'AAAAA' - - mock_update_paper_level = mocker.patch('figureraspbian.photobooth.update_paper_level_async') - mock_claim_new_codes = mocker.patch('figureraspbian.photobooth.claim_new_codes_async') - mock_upload_portrait = mocker.patch('figureraspbian.photobooth.upload_portrait_async') - - trigger() - - assert mock_camera.capture.called - assert mock_printer.print_ticket.called - assert mock_update_paper_level.called - assert mock_claim_new_codes.called - assert mock_upload_portrait.called - - - -class TestButtton: - - def test_register_when_pressed(self): - """ it should register when_pressed callback""" - mock_function = Mock() - def when_pressed(): - mock_function() - button = Button(1, 0.05, 2) - button.when_pressed = when_pressed - button._fire_activated() - assert mock_function.called - - def test_register_when_held(self): - """ it should register when_held callback """ - mock_function = Mock() - def when_held(): - mock_function() - button = Button(1, 0.05, 2) - button.when_held = when_held - button._fire_held() - assert mock_function.called - - -class TestEventThread: - - def test_fire_events(self): - """ - it set active and inactive events based on parent button value - """ - mock_button = create_autospec(Button) - mock_button._last_state = None - mock_button._inactive_event = ThreadEvent() - mock_button._active_event = ThreadEvent() - mock_button._holding = ThreadEvent() - - event_thread = EventThread(mock_button) - mock_button.value.return_value = 0 - event_thread._fire_events(mock_button) - - assert mock_button._inactive_event.is_set() - - mock_button.value.return_value = 1 - event_thread._fire_events(mock_button) - - assert mock_button._active_event.is_set() - assert mock_button._fire_activated.called - - - -class TestHoldThread: - - def test_hold(self): - """ it should fire held callback if the button is held enough time""" - - mock_button = create_autospec(Button) - mock_button.hold_time = 0.1 - mock_button._fire_held = Mock() - mock_button._inactive_event = ThreadEvent() - mock_button._holding = ThreadEvent() - - hold_thread = HoldThread(mock_button) - mock_button._holding.set() - hold_thread.start() - time.sleep(0.2) - mock_button._inactive_event.set() - time.sleep(0.1) - hold_thread.stop() - assert mock_button._fire_held.called - - def test_not_hold(self): - """ it should not fire heled callback if the button is not held enough time""" - - mock_button = create_autospec(PiFaceDigitalButton) - mock_button.hold_time = 0.2 - mock_button._fire_held = Mock() - mock_button._inactive_event = ThreadEvent() - mock_button._holding = ThreadEvent() - - hold_thread = HoldThread(mock_button) - mock_button._holding.set() - hold_thread.start() - time.sleep(0.1) - mock_button._inactive_event.set() - time.sleep(0.1) - hold_thread.stop() - assert not mock_button._fire_held.called - - - - - - diff --git a/test_ticket.jpg b/test_ticket.jpg deleted file mode 100644 index e8d12e6..0000000 Binary files a/test_ticket.jpg and /dev/null differ diff --git a/test_ticket.png b/test_ticket.png new file mode 100644 index 0000000..f5614b4 Binary files /dev/null and b/test_ticket.png differ diff --git a/wifi-connect/.bowerrc b/wifi-connect/.bowerrc deleted file mode 100644 index f46aac8..0000000 --- a/wifi-connect/.bowerrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "directory": "src/public/bower_components", - "storage": { - "packages": ".bower-cache", - "registry": ".bower-registry" - }, - "tmp": ".bower-tmp" -} \ No newline at end of file diff --git a/wifi-connect/.gitignore b/wifi-connect/.gitignore deleted file mode 100644 index 4d94549..0000000 --- a/wifi-connect/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Logs -logs -*.log - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directory -# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git -node_modules - -.bower-cache -.bower-registry -src/public/bower_components \ No newline at end of file diff --git a/wifi-connect/assets/bind/db.catchall b/wifi-connect/assets/bind/db.catchall deleted file mode 100644 index bc2b37c..0000000 --- a/wifi-connect/assets/bind/db.catchall +++ /dev/null @@ -1,11 +0,0 @@ -$TTL 300 -@ IN SOA . root.localhost. ( - 1 ; Serial - 3600 ; Refresh - 600 ; Retry - 86400 ; Expire - 300 ) ; Negative Cache TTL - - IN NS . -. IN A 192.168.0.1 -*. IN A 192.168.0.1 diff --git a/wifi-connect/assets/bind/named.conf b/wifi-connect/assets/bind/named.conf deleted file mode 100644 index 85fa799..0000000 --- a/wifi-connect/assets/bind/named.conf +++ /dev/null @@ -1,11 +0,0 @@ -options { - directory "/etc/bind"; - pid-file "/var/run/named/pid"; - allow-query { any; }; - allow-recursion { any; }; -}; - -zone "." { - type master; - file "/etc/bind/db.catchall"; -}; diff --git a/wifi-connect/bower.json b/wifi-connect/bower.json deleted file mode 100644 index e06285c..0000000 --- a/wifi-connect/bower.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "resin-wifi-connect", - "version": "0.0.0", - "homepage": "https://github.com/pcarranzav/resin-wifi-connect", - "authors": [ - "Pablo Carranza Vélez " - ], - "license": "MIT", - "ignore": [ - "**/.*", - "node_modules", - "bower_components", - "src/public/bower_components", - "test", - "tests" - ], - "dependencies": { - "bootstrap": "~3.3.5" - } -} diff --git a/wifi-connect/coffeelint.json b/wifi-connect/coffeelint.json deleted file mode 100644 index bce46f9..0000000 --- a/wifi-connect/coffeelint.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "coffeescript_error": { - "level": "error" - }, - "arrow_spacing": { - "name": "arrow_spacing", - "level": "error" - }, - "no_tabs": { - "name": "no_tabs", - "level": "ignore" - }, - "no_trailing_whitespace": { - "name": "no_trailing_whitespace", - "level": "error", - "allowed_in_comments": false, - "allowed_in_empty_lines": false - }, - "max_line_length": { - "name": "max_line_length", - "value": 120, - "level": "error", - "limitComments": true - }, - "line_endings": { - "name": "line_endings", - "level": "ignore", - "value": "unix" - }, - "no_trailing_semicolons": { - "name": "no_trailing_semicolons", - "level": "error" - }, - "indentation": { - "name": "indentation", - "value": 1, - "level": "error" - }, - "camel_case_classes": { - "name": "camel_case_classes", - "level": "error" - }, - "colon_assignment_spacing": { - "name": "colon_assignment_spacing", - "level": "error", - "spacing": { - "left": 0, - "right": 1 - } - }, - "no_implicit_braces": { - "name": "no_implicit_braces", - "level": "ignore", - "strict": false - }, - "no_plusplus": { - "name": "no_plusplus", - "level": "ignore" - }, - "no_throwing_strings": { - "name": "no_throwing_strings", - "level": "error" - }, - "no_backticks": { - "name": "no_backticks", - "level": "warn" - }, - "no_implicit_parens": { - "name": "no_implicit_parens", - "strict": false, - "level": "ignore" - }, - "no_empty_param_list": { - "name": "no_empty_param_list", - "level": "error" - }, - "no_stand_alone_at": { - "name": "no_stand_alone_at", - "level": "ignore" - }, - "space_operators": { - "name": "space_operators", - "level": "error" - }, - "duplicate_key": { - "name": "duplicate_key", - "level": "error" - }, - "empty_constructor_needs_parens": { - "name": "empty_constructor_needs_parens", - "level": "ignore" - }, - "cyclomatic_complexity": { - "name": "cyclomatic_complexity", - "value": 10, - "level": "ignore" - }, - "newlines_after_classes": { - "name": "newlines_after_classes", - "value": 3, - "level": "ignore" - }, - "no_unnecessary_fat_arrows": { - "name": "no_unnecessary_fat_arrows", - "level": "error" - }, - "missing_fat_arrows": { - "name": "missing_fat_arrows", - "level": "ignore" - }, - "non_empty_constructor_needs_parens": { - "name": "non_empty_constructor_needs_parens", - "level": "ignore" - }, - "no_unnecessary_double_quotes": { - "name": "no_unnecessary_double_quotes", - "level": "error" - }, - "no_debugger": { - "name": "no_debugger", - "level": "warn" - }, - "no_interpolation_in_single_quotes": { - "name": "no_interpolation_in_single_quotes", - "level": "error" - } -} diff --git a/wifi-connect/package.json b/wifi-connect/package.json deleted file mode 100644 index fcd3a3d..0000000 --- a/wifi-connect/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "resin-wifi-connect", - "version": "1.0.0", - "repository": "https://github.com/pcarranzav/resin-wifi-connect", - "description": "A tool to allow wifi configuration to be set via a captive portal", - "scripts": { - "start": "cd src && node app.js", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "Pablo Carranza Vélez ", - "license": "ISC", - "dependencies": { - "async": "^1.3.0", - "bluebird": "^2.9.34", - "body-parser": "^1.13.1", - "bower": "^1.4.1", - "coffee-script": "~1.9.3", - "connman-simplified-resin": "^0.1.5", - "express": "^4.13.0" - } -} diff --git a/wifi-connect/src/app.coffee b/wifi-connect/src/app.coffee deleted file mode 100644 index a66fb21..0000000 --- a/wifi-connect/src/app.coffee +++ /dev/null @@ -1,164 +0,0 @@ -Promise = require('bluebird') -connman = Promise.promisifyAll(require('connman-simplified-resin')()) -express = require('express') -app = express() -bodyParser = require('body-parser') -iptables = Promise.promisifyAll(require('./iptables')) -spawn = require('child_process').spawn -exec = require('child_process').exec -os = require('os') -async = require('async') -fs = Promise.promisifyAll(require('fs')) - -config = require('./wifi.json') -ssid = process.env.PORTAL_SSID or config.ssid -passphrase = process.env.PORTAL_PASSPHRASE or config.passphrase -port = process.env.PORTAL_PORT or config.port - -server = null -ssidList = null -dnsServer = null - -connectionFile = '/data/connections.json' -try - connectionsFromFile = require(connectionFile) -catch - connectionsFromFile = [] - -ignore = -> - -getIptablesRules = (callback) -> - async.retry {times: 100, interval: 200}, (cb) -> - Promise.try -> - os.networkInterfaces().tether[0].address - .nodeify(cb) - , (err, myIP) -> - return callback(err) if err? - callback null, [ - table: 'nat' - rule: 'PREROUTING -i tether -j TETHER' - , - table: 'nat' - rule: "TETHER -p tcp --dport 80 -j DNAT --to-destination #{myIP}:#{port}" - , - table: 'nat' - rule: "TETHER -p tcp --dport 443 -j DNAT --to-destination #{myIP}:#{port}" - , - table: 'nat' - rule: "TETHER -p udp --dport 53 -j DNAT --to-destination #{myIP}:53" - ] - - -startServer = (wifi) -> - console.log('Getting networks list') - wifi.getNetworksAsync() - .catch (err) -> - throw err unless err.message == 'No WiFi networks found' - return [] - .then (list) -> - ssidList = list - wifi.openHotspotAsync(ssid, passphrase) - .then -> - console.log('Hotspot enabled') - dnsServer = spawn('named', ['-f']) - getIptablesRules (err, iptablesRules) -> - throw err if err? - iptables.appendManyAsync(iptablesRules) - .then -> - console.log('Captive portal enabled') - server = app.listen port, (err) -> - throw err if err? - console.log('Server listening') - -connectOrStartServer = (wifi, retryCallback) -> - console.log('Trying to join previously known networks') - Promise.each connectionsFromFile, (conn) -> - wifi.joinAsync(conn.ssid, conn.passphrase) - .then -> - console.log('Joined! Exiting.') - retryCallback() - .catch(ignore) - .finally -> - startServer(wifi) - -saveToFile = (ssid, passphrase) -> - connectionsFromFile.push({ ssid, passphrase }) - fs.openAsync(connectionFile, 'w') - .tap (fd) -> - buf = new Buffer(JSON.stringify(connectionsFromFile)) - fs.writeAsync(fd, buf, 0, buf.length, null) - .tap (fd) -> - fs.fsyncAsync(fd) - .then (fd) -> - fs.closeAsync(fd) - -console.log('Starting node connman app') -manageConnection = (retryCallback) -> - connman.initAsync() - .then -> - console.log('Connman initialized') - connman.initWiFiAsync() - .spread (wifi, properties) -> - wifi = Promise.promisifyAll(wifi) - console.log('WiFi initialized') - - app.use(bodyParser()) - app.use(express.static(__dirname + '/public')) - app.get '/ssids', (req, res) -> - res.send(ssidList) - app.post '/connect', (req, res) -> - if not (req.body.ssid? and req.body.passphrase?) - return res.sendStatus(400) - console.log('Selected ' + req.body.ssid) - res.send('OK') - server.close() - iptables.deleteAsync({ table: 'nat', rule: 'PREROUTING -i tether -j TETHER'}) - .catch(ignore) - .then -> - iptables.flushAsync('nat', 'TETHER') - .catch(ignore) - .then -> - dnsServer.kill() - console.log('Server closed and captive portal disabled') - Promise.fromNode (callback) -> - async.retry {times: 3, interval: 1000}, (done) -> - wifi.joinAsync(req.body.ssid, req.body.passphrase) - .nodeify(done) - , callback - .then -> - saveToFile(req.body.ssid, req.body.passphrase) - .then -> - console.log('Joined! Exiting.') - retryCallback() - .catch (err) -> - console.log('Error joining network', err, err.stack) - return startServer(wifi) - - app.use (req, res) -> - res.redirect('/') - - # Ensure tethering is disabled before starting - wifi.closeHotspotAsync() - .catch(ignore) - .then -> - # Create TETHER iptables chain (will silently fail if it already exists) - iptables.createChainAsync('nat', 'TETHER') - .catch(ignore) - .then -> - iptables.deleteAsync({ table: 'nat', rule: 'PREROUTING -i tether -j TETHER'}) - .catch(ignore) - .then -> - iptables.flushAsync('nat', 'TETHER') - .then -> - if !properties.connected - connectOrStartServer(wifi, retryCallback) - else - console.log('Already connected') - retryCallback() - .catch (err) -> - console.log(err) - return retryCallback(err) - -async.retry {times: 10, interval: 1000}, manageConnection, (err) -> - throw err if err? - process.exit() diff --git a/wifi-connect/src/iptables.coffee b/wifi-connect/src/iptables.coffee deleted file mode 100644 index 58f0e1d..0000000 --- a/wifi-connect/src/iptables.coffee +++ /dev/null @@ -1,24 +0,0 @@ -async = require('async') -exec = require('child_process').exec - -iptables = {} - -iptables.append = (rule, cb) -> - exec("iptables -t #{rule.table} -A #{rule.rule}", cb) - -iptables.delete = (rule, cb) -> - exec("iptables -t #{rule.table} -D #{rule.rule}", cb) - -iptables.createChain = (table, chain, cb) -> - exec("iptables -t #{table} -N #{chain}", cb) - -iptables.flush = (table, chain, cb) -> - exec("iptables -t #{table} -F #{chain}", cb) - -iptables.appendMany = (rules, cb) -> - async.eachSeries rules, iptables.append, cb - -iptables.deleteMany = (rules, cb) -> - async.eachSeries rules, iptables.delete, cb - -module.exports = iptables diff --git a/wifi-connect/src/public/img/favicon.png b/wifi-connect/src/public/img/favicon.png deleted file mode 100644 index 229c4ad..0000000 Binary files a/wifi-connect/src/public/img/favicon.png and /dev/null differ diff --git a/wifi-connect/src/public/img/logo.svg b/wifi-connect/src/public/img/logo.svg deleted file mode 100644 index ae052fa..0000000 --- a/wifi-connect/src/public/img/logo.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/wifi-connect/src/public/index.html b/wifi-connect/src/public/index.html deleted file mode 100644 index 0f4394e..0000000 --- a/wifi-connect/src/public/index.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - Resin WiFi chooser - - - - - - - - - - - - - - - -
-
-
-

Hi! Please choose your wifi from the list.

-
-
-
-
-
-
- -
- -
-
-
- -
- -
-
-
- -
-
-
-
- - -
- - diff --git a/wifi-connect/src/public/js/index.js b/wifi-connect/src/public/js/index.js deleted file mode 100644 index b928c58..0000000 --- a/wifi-connect/src/public/js/index.js +++ /dev/null @@ -1,20 +0,0 @@ -$(function(){ - $.get("/ssids", function(data){ - if(data.length == 0){ - $('.before-submit').hide(); - $('#no-networks-message').removeClass('hidden'); - } else { - $.each(data, function(i, val){ - $("#ssid-select").append(""); - }); - } - }) - - $('#connect-form').submit(function(ev){ - $.post('/connect', $('#connect-form').serialize(), function(data){ - $('.before-submit').hide(); - $('#submit-message').removeClass('hidden'); - }); - ev.preventDefault(); - }); -}); diff --git a/wifi-connect/src/wifi.json b/wifi-connect/src/wifi.json deleted file mode 100644 index 666dd1a..0000000 --- a/wifi-connect/src/wifi.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ssid": "FigureAP", - "passhprase": null, - "port": 8081 -} diff --git a/xvfb b/xvfb new file mode 100644 index 0000000..df47a36 --- /dev/null +++ b/xvfb @@ -0,0 +1,24 @@ +XVFB=/usr/bin/Xvfb +XVFBARGS=":1 -screen 0 100x100x24 -ac +extension GLX +render -noreset" +PIDFILE=/var/run/xvfb.pid +case "$1" in + start) + echo -n "Starting virtual X frame buffer: Xvfb" + start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile --background --exec $XVFB -- $XVFBARGS + echo "." + ;; + stop) + echo -n "Stopping virtual X frame buffer: Xvfb" + start-stop-daemon --stop --quiet --pidfile $PIDFILE + echo "." + ;; + restart) + $0 stop + $0 start + ;; + *) + echo "Usage: /etc/init.d/xvfb {start|stop|restart}" + exit 1 +esac + +exit 0 \ No newline at end of file