From 73c6297ac6bf6ecdbcf10b1ef389da1fe315b56b Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Sun, 28 Nov 2021 14:15:58 +0000 Subject: [PATCH] tobru mods --- .gitignore | 3 + __init__.py | 79 +++++++++++++++++ actions/__init__.py | 22 +++++ actions/base.py | 108 +++++++++++++++++++++++ actions/playback.py | 86 ++++++++++++++++++ actions/shutdown.py | 30 +++++++ actions/tracklist.py | 41 +++++++++ actions/volume.py | 50 +++++++++++ ext.conf | 2 + frontend.py | 46 ++++++++++ registry.py | 142 ++++++++++++++++++++++++++++++ sound.py | 19 ++++ sounds/fail.wav | Bin 0 -> 61966 bytes sounds/success.wav | Bin 0 -> 9054 bytes threads/__init__.py | 6 ++ threads/gpio_handler.py | 84 ++++++++++++++++++ threads/tag_reader.py | 119 +++++++++++++++++++++++++ web.py | 171 ++++++++++++++++++++++++++++++++++++ webui/index.html | 42 +++++++++ webui/script.js | 188 ++++++++++++++++++++++++++++++++++++++++ webui/style.css | 181 ++++++++++++++++++++++++++++++++++++++ 21 files changed, 1419 insertions(+) create mode 100644 .gitignore create mode 100644 __init__.py create mode 100644 actions/__init__.py create mode 100644 actions/base.py create mode 100644 actions/playback.py create mode 100644 actions/shutdown.py create mode 100644 actions/tracklist.py create mode 100644 actions/volume.py create mode 100644 ext.conf create mode 100644 frontend.py create mode 100644 registry.py create mode 100644 sound.py create mode 100644 sounds/fail.wav create mode 100644 sounds/success.wav create mode 100644 threads/__init__.py create mode 100644 threads/gpio_handler.py create mode 100644 threads/tag_reader.py create mode 100644 web.py create mode 100644 webui/index.html create mode 100644 webui/script.js create mode 100644 webui/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9188cc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.py[cod] +*$py.class diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5afb131 --- /dev/null +++ b/__init__.py @@ -0,0 +1,79 @@ +''' +Mopidy Pummeluff Python module. +''' + +import os + +import pkg_resources +import mopidy + +from .frontend import PummeluffFrontend +from .web import LatestHandler, RegistryHandler, RegisterHandler, UnregisterHandler, \ + ActionClassesHandler + + +__version__ = pkg_resources.get_distribution('Mopidy-Pummeluff').version + +def app_factory(config, core): # pylint: disable=unused-argument + ''' + App factory for the web apps. + + :param mopidy.config config: The mopidy config + :param mopidy.core.Core: The mopidy core + + :return: The registered app request handlers + :rtype: list + ''' + return [ + ('/latest/', LatestHandler), + ('/registry/', RegistryHandler), + ('/register/', RegisterHandler), + ('/unregister/', UnregisterHandler), + ('/action-classes/', ActionClassesHandler), + ] + + +class Extension(mopidy.ext.Extension): + ''' + Mopidy Pummeluff extension. + ''' + + dist_name = 'Mopidy-Pummeluff' + ext_name = 'pummeluff' + version = __version__ + + def get_default_config(self): # pylint: disable=no-self-use + ''' + Return the default config. + + :return: The default config + ''' + conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf') + return mopidy.config.read(conf_file) + + def get_config_schema(self): + ''' + Return the config schema. + + :return: The config schema + ''' + schema = super(Extension, self).get_config_schema() + return schema + + def setup(self, registry): + ''' + Setup the extension. + + :param mopidy.ext.Registry: The mopidy registry + ''' + registry.add('frontend', PummeluffFrontend) + + registry.add('http:static', { + 'name': self.ext_name, + 'path': os.path.join(os.path.dirname(__file__), 'webui'), + }) + + registry.add('http:app', { + 'name': self.ext_name, + 'factory': app_factory, + }) diff --git a/actions/__init__.py b/actions/__init__.py new file mode 100644 index 0000000..42a9a83 --- /dev/null +++ b/actions/__init__.py @@ -0,0 +1,22 @@ +''' +Python module for Mopidy Pummeluff tags. +''' + +__all__ = ( + 'PlayPause', + 'Stop', + 'PreviousTrack', + 'NextTrack', + 'Shutdown', + 'Tracklist', + 'Volume', +) + +from .playback import PlayPause, Stop, PreviousTrack, NextTrack +from .shutdown import Shutdown +from .tracklist import Tracklist +from .volume import Volume + +ACTIONS = {} +for action in __all__: + ACTIONS[action] = globals()[action].__doc__.strip() diff --git a/actions/base.py b/actions/base.py new file mode 100644 index 0000000..1e1ac69 --- /dev/null +++ b/actions/base.py @@ -0,0 +1,108 @@ +''' +Python module for Mopidy Pummeluff base action. +''' + +__all__ = ( + 'Action', +) + +from logging import getLogger +from inspect import getfullargspec + +LOGGER = getLogger(__name__) + + +class Action: + ''' + Base RFID tag class, which will implement the factory pattern in Python's + own :py:meth:`__new__` method. + ''' + + @classmethod + def execute(cls, core): + ''' + Execute the action. + + :param mopidy.core.Core core: The mopidy core instance + + :raises NotImplementedError: When class method is not implemented + ''' + name = cls.__name__ + error = 'Missing execute class method in the %s class' + LOGGER.error(error, name) + raise NotImplementedError(error % name) + + def __init__(self, uid, alias=None, parameter=None): + ''' + Concstructor. + ''' + self.uid = uid + self.alias = alias + self.parameter = parameter + self.scanned = None + + def __str__(self): + ''' + String representation of tag. + + :return: The alias + :rtype: str + ''' + return self.alias or self.uid + + def __repr__(self): + ''' + Instance representation of tag. + + :return: The class name and UID + :rtype: str + ''' + identifier = self.alias or self.uid + return f'<{self.__class__.__name__} {identifier}>' + + def __call__(self, core): + ''' + Action method which is called when the tag is detected on the RFID + reader. + + :param mopidy.core.Core core: The mopidy core instance + ''' + args = [core] + if self.parameter: + args.append(self.parameter) + self.execute(*args) + + def as_dict(self, include_scanned=False): + ''' + Dict representation of the tag. + + :param bool include_scanned: Include scanned timestamp + + :return: The dict version of the tag + :rtype: dict + ''' + data = { + 'action_class': self.__class__.__name__, + 'uid': self.uid, + 'alias': self.alias or '', + 'parameter': self.parameter or '', + } + + if include_scanned: + data['scanned'] = self.scanned + + return data + + def validate(self): + ''' + Validate parameter. + + :raises ValueError: When parameter is not allowed but defined + ''' + parameterised = len(getfullargspec(self.execute).args) > 2 + + if parameterised and not self.parameter: + raise ValueError('Parameter required for this tag') + + if not parameterised and self.parameter: + raise ValueError('No parameter allowed for this tag') diff --git a/actions/playback.py b/actions/playback.py new file mode 100644 index 0000000..8c13a25 --- /dev/null +++ b/actions/playback.py @@ -0,0 +1,86 @@ +''' +Python module for Mopidy Pummeluff playback actions. +''' + +__all__ = ( + 'PlayPause', + 'Stop', + 'PreviousTrack', + 'NextTrack', +) + +from logging import getLogger + +from .base import Action + +LOGGER = getLogger(__name__) + + +class PlayPause(Action): + ''' + Pauses or resumes the playback, based on the current state. + ''' + + @classmethod + def execute(cls, core): + ''' + Pause or resume the playback. + + :param mopidy.core.Core core: The mopidy core instance + ''' + playback = core.playback + + if playback.get_state().get() == 'playing': + LOGGER.info('Pausing the playback') + playback.pause() + else: + LOGGER.info('Resuming the playback') + playback.resume() + + +class Stop(Action): + ''' + Stops the playback. + ''' + + @classmethod + def execute(cls, core): + ''' + Stop playback. + + :param mopidy.core.Core core: The mopidy core instance + ''' + LOGGER.info('Stopping playback') + core.playback.stop() + + +class PreviousTrack(Action): + ''' + Changes to the previous track. + ''' + + @classmethod + def execute(cls, core): + ''' + Change to previous track. + + :param mopidy.core.Core core: The mopidy core instance + ''' + LOGGER.info('Changing to previous track') + core.playback.previous() + + +class NextTrack(Action): + ''' + Changes to the next track. + ''' + + @classmethod + def execute(cls, core): + ''' + Change to next track. + + :param mopidy.core.Core core: The mopidy core instance + ''' + LOGGER.info('Changing to next track') + core.playback.next() diff --git a/actions/shutdown.py b/actions/shutdown.py new file mode 100644 index 0000000..35d5f87 --- /dev/null +++ b/actions/shutdown.py @@ -0,0 +1,30 @@ +''' +Python module for Mopidy Pummeluff shutdown tag. +''' + +__all__ = ( + 'Shutdown', +) + +from logging import getLogger +from os import system + +from .base import Action + +LOGGER = getLogger(__name__) + + +class Shutdown(Action): + ''' + Shutting down the system. + ''' + + @classmethod + def execute(cls, core): + ''' + Shutdown. + + :param mopidy.core.Core core: The mopidy core instance + ''' + LOGGER.info('Shutting down') + system('sudo /sbin/shutdown -h now') diff --git a/actions/tracklist.py b/actions/tracklist.py new file mode 100644 index 0000000..3cbb155 --- /dev/null +++ b/actions/tracklist.py @@ -0,0 +1,41 @@ +''' +Python module for Mopidy Pummeluff tracklist tag. +''' + +__all__ = ( + 'Tracklist', +) + +from logging import getLogger + +from .base import Action + +LOGGER = getLogger(__name__) + + +class Tracklist(Action): + ''' + Replaces the current tracklist with the URI retreived from the tag's + parameter. + ''' + + @classmethod + def execute(cls, core, uri): # pylint: disable=arguments-differ + ''' + Replace tracklist and play. + + :param mopidy.core.Core core: The mopidy core instance + :param str uri: An URI for the tracklist replacement + ''' + LOGGER.info('Replacing tracklist with URI "%s"', uri) + + playlists = [playlist.uri for playlist in core.playlists.as_list().get()] + + if uri in playlists: + uris = [item.uri for item in core.playlists.get_items(uri).get()] + else: + uris = [uri] + + core.tracklist.clear() + core.tracklist.add(uris=uris) + core.playback.play() diff --git a/actions/volume.py b/actions/volume.py new file mode 100644 index 0000000..741e930 --- /dev/null +++ b/actions/volume.py @@ -0,0 +1,50 @@ +''' +Python module for Mopidy Pummeluff volume tag. +''' + +__all__ = ( + 'Volume', +) + +from logging import getLogger + +from .base import Action + +LOGGER = getLogger(__name__) + + +class Volume(Action): + ''' + Sets the volume to the percentage value retreived from the tag's parameter. + ''' + + @classmethod + def execute(cls, core, volume): # pylint: disable=arguments-differ + ''' + Set volume of the mixer. + + :param mopidy.core.Core core: The mopidy core instance + :param volume: The new (percentage) volume + :type volume: int|str + ''' + LOGGER.info('Setting volume to %s', volume) + try: + core.mixer.set_volume(int(volume)) + except ValueError as ex: + LOGGER.error(str(ex)) + + def validate(self): + ''' + Validates if the parameter is an integer between 0 and 100. + + :param mixed parameter: The parameter + + :raises ValueError: When parameter is invalid + ''' + super().validate() + + try: + number = int(self.parameter) + assert 0 <= number <= 100 + except (ValueError, AssertionError): + raise ValueError('Volume parameter has to be a number between 0 and 100') diff --git a/ext.conf b/ext.conf new file mode 100644 index 0000000..b80774b --- /dev/null +++ b/ext.conf @@ -0,0 +1,2 @@ +[pummeluff] +enabled = True \ No newline at end of file diff --git a/frontend.py b/frontend.py new file mode 100644 index 0000000..a137794 --- /dev/null +++ b/frontend.py @@ -0,0 +1,46 @@ +''' +Python module for Mopidy Pummeluff frontend. +''' + +__all__ = ( + 'PummeluffFrontend', +) + +from threading import Event +from logging import getLogger + +import pykka +from mopidy import core as mopidy_core + +from .threads import GPIOHandler, TagReader + + +LOGGER = getLogger(__name__) + + +class PummeluffFrontend(pykka.ThreadingActor, mopidy_core.CoreListener): + ''' + Pummeluff frontend which basically reacts to GPIO button pushes and touches + of RFID tags. + ''' + + def __init__(self, config, core): # pylint: disable=unused-argument + super().__init__() + self.core = core + self.stop_event = Event() + self.gpio_handler = GPIOHandler(core=core, stop_event=self.stop_event) + self.tag_reader = TagReader(core=core, stop_event=self.stop_event) + + def on_start(self): + ''' + Start GPIO handler & tag reader threads. + ''' + self.gpio_handler.start() + self.tag_reader.start() + + def on_stop(self): + ''' + Set threading stop event to tell GPIO handler & tag reader threads to + stop their operations. + ''' + self.stop_event.set() diff --git a/registry.py b/registry.py new file mode 100644 index 0000000..9070e98 --- /dev/null +++ b/registry.py @@ -0,0 +1,142 @@ +''' +Python module for Mopidy Pummeluff registry. +''' + +__all__ = ( + 'RegistryDict', + 'REGISTRY', +) + +import os +import json +from logging import getLogger + +from mopidy_pummeluff import actions + + +LOGGER = getLogger(__name__) + + +class RegistryDict(dict): + ''' + Class which can be used to retreive and write RFID tags to the registry. + ''' + + registry_path = '/var/lib/mopidy/pummeluff/tags.json' + + def __init__(self): + ''' + Constructor. + + Automatically reads the registry if it exists. + ''' + super().__init__() + + if os.path.exists(self.registry_path): + self.read() + else: + LOGGER.warning('Registry not existing yet on "%s"', self.registry_path) + + @classmethod + def unserialize_item(cls, item): + ''' + Unserialize an item from the persistent storage on filesystem to a + native action. + + :param tuple item: The item + + :return: The action + :rtype: actions.Action + ''' + if 'tag_class' in item: + item['action_class'] = item.pop('tag_class') + + return item['uid'], cls.init_action(**item) + + @classmethod + def init_action(cls, action_class, uid, alias=None, parameter=None): + ''' + Initialise a new action instance. + + :param str action_class: The action class + :param str uid: The RFID UID + :param str alias: The alias + :param str parameter: The parameter + + :return: The action instance + :rtype: actions.Action + ''' + uid = str(uid).strip() + action_class = getattr(actions, action_class) + + return action_class(uid, alias, parameter) + + def read(self): + ''' + Read registry from disk. + + :raises IOError: When registry file on disk is missing + ''' + LOGGER.debug('Reading registry from %s', self.registry_path) + + with open(self.registry_path) as f: + data = json.load(f) + self.clear() + self.update((self.unserialize_item(item) for item in data)) + + def write(self): + ''' + Write registry to disk. + ''' + LOGGER.debug('Writing registry to %s', self.registry_path) + + config = self.registry_path + directory = os.path.dirname(config) + + if not os.path.exists(directory): + os.makedirs(directory) + + with open(config, 'w') as f: + json.dump([action.as_dict() for action in self.values()], f, indent=4) + + def register(self, action_class, uid, alias=None, parameter=None): + ''' + Register a new tag in the registry. + + :param str action_class: The action class + :param str uid: The UID + :param str alias: The alias + :param str parameter: The parameter (optional) + + :return: The action + :rtype: actions.Action + ''' + LOGGER.info('Registering %s tag %s with parameter "%s"', action_class, uid, parameter) + + action = self.init_action( + action_class=action_class, + uid=uid, + alias=alias, + parameter=parameter + ) + + action.validate() + + self[uid] = action + self.write() + + return action + + def unregister(self, uid): + ''' + Unregister a tag from the registry. + + :param str uid: The UID + ''' + LOGGER.info('Unregistering tag %s', uid) + + del self[uid] + self.write() + + +REGISTRY = RegistryDict() diff --git a/sound.py b/sound.py new file mode 100644 index 0000000..1bec500 --- /dev/null +++ b/sound.py @@ -0,0 +1,19 @@ +''' +Python module to play sounds. +''' + +__all__ = ( + 'play_sound', +) + +from os import path, system + + +def play_sound(sound): + ''' + Play sound via aplay. + + :param str sound: The name of the sound file + ''' + file_path = path.join(path.dirname(__file__), 'sounds', sound) + system('aplay -q {}'.format(file_path)) diff --git a/sounds/fail.wav b/sounds/fail.wav new file mode 100644 index 0000000000000000000000000000000000000000..5f7727bd9e7e956906a72942f3ccb25c949b7878 GIT binary patch literal 61966 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 LD}x{Z^e6lu2RI;u literal 0 HcmV?d00001 diff --git a/sounds/success.wav b/sounds/success.wav new file mode 100644 index 0000000000000000000000000000000000000000..1862a360abbbe73bb6f1f70415937866e4cef64a GIT binary patch literal 9054 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 0.25): + LOGGER.debug('Button at pin %s was pushed', pin) + play_sound('success.wav') + self.button_pins[pin].execute(self.core) + self.timestamps[pin] = now diff --git a/threads/tag_reader.py b/threads/tag_reader.py new file mode 100644 index 0000000..4bf449b --- /dev/null +++ b/threads/tag_reader.py @@ -0,0 +1,119 @@ +''' +Python module for the dedicated Mopidy Pummeluff threads. +''' + +__all__ = ( + 'TagReader', +) + +from threading import Thread +from time import time +from logging import getLogger + +import RPi.GPIO as GPIO +from pirc522 import RFID + +from mopidy_pummeluff.registry import REGISTRY +from mopidy_pummeluff.actions.base import Action +from mopidy_pummeluff.sound import play_sound + +LOGGER = getLogger(__name__) + + +class ReadError(Exception): + ''' + Exception which is thrown when an RFID read error occurs. + ''' + + +class TagReader(Thread): + ''' + Thread which reads RFID tags from the RFID reader. + + Because the RFID reader algorithm is reacting to an IRQ (interrupt), it is + blocking as long as no tag is touched, even when Mopidy is exiting. Thus, + we're running the thread as daemon thread, which means it's exiting at the + same moment as the main thread (aka Mopidy core) is exiting. + ''' + daemon = True + latest = None + + def __init__(self, core, stop_event): + ''' + Class constructor. + + :param mopidy.core.Core core: The mopidy core instance + :param threading.Event stop_event: The stop event + ''' + super().__init__() + self.core = core + self.stop_event = stop_event + #self.rfid = RFID() + self.rfid = RFID(pin_irq=15) + + def run(self): + ''' + Run RFID reading loop. + ''' + rfid = self.rfid + prev_time = time() + prev_uid = '' + + while not self.stop_event.is_set(): + rfid.wait_for_tag() + + try: + now = time() + uid = self.read_uid() + + if now - prev_time > 1 or uid != prev_uid: + LOGGER.info('Tag %s read', uid) + self.handle_uid(uid) + + prev_time = now + prev_uid = uid + + except ReadError: + pass + + GPIO.cleanup() # pylint: disable=no-member + + def read_uid(self): + ''' + Return the UID from the tag. + + :return: The hex UID + :rtype: string + ''' + rfid = self.rfid + + error, data = rfid.request() # pylint: disable=unused-variable + if error: + raise ReadError('Could not read tag') + + error, uid_chunks = rfid.anticoll() + if error: + raise ReadError('Could not read UID') + + uid = '{0[0]:02X}{0[1]:02X}{0[2]:02X}{0[3]:02X}'.format(uid_chunks) # pylint: disable=invalid-format-index + return uid + + def handle_uid(self, uid): + ''' + Handle the scanned tag / retreived UID. + + :param str uid: The UID + ''' + try: + action = REGISTRY[str(uid)] + LOGGER.info('Triggering action of registered tag') + play_sound('success.wav') + action(self.core) + + except KeyError: + LOGGER.info('Tag is not registered, thus doing nothing') + play_sound('fail.wav') + action = Action(uid=uid) + + action.scanned = time() + TagReader.latest = action diff --git a/web.py b/web.py new file mode 100644 index 0000000..e3c47d9 --- /dev/null +++ b/web.py @@ -0,0 +1,171 @@ +''' +Python module for Mopidy Pummeluff web classes. +''' + +__all__ = ( + 'LatestHandler', + 'RegistryHandler', + 'RegisterHandler', + 'UnregisterHandler', + 'ActionClassesHandler', +) + +from json import dumps +from logging import getLogger + +from tornado.web import RequestHandler + +from mopidy_pummeluff.registry import REGISTRY +from mopidy_pummeluff.actions import ACTIONS +from mopidy_pummeluff.threads import TagReader + +LOGGER = getLogger(__name__) + + +class LatestHandler(RequestHandler): # pylint: disable=abstract-method + ''' + Request handler which returns the latest scanned tag. + ''' + + def get(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle GET request. + ''' + tag = TagReader.latest + + LOGGER.debug('Returning latest tag %s', tag) + + if tag is None: + data = { + 'success': False, + 'message': 'No tag scanned yet' + } + + else: + data = { + 'success': True, + 'message': 'Scanned tag found', + } + + data.update(tag.as_dict(include_scanned=True)) + + self.set_header('Content-type', 'application/json') + self.write(dumps(data)) + + +class RegistryHandler(RequestHandler): # pylint: disable=abstract-method + ''' + Request handler which returns all registered tags. + ''' + + def get(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle GET request. + ''' + tags_list = [] + + for tag in REGISTRY.values(): + tags_list.append(tag.as_dict()) + + data = { + 'success': True, + 'message': 'Registry successfully read', + 'tags': tags_list + } + + self.set_header('Content-type', 'application/json') + self.write(dumps(data)) + + +class RegisterHandler(RequestHandler): # pylint: disable=abstract-method + ''' + Request handler which registers an RFID tag in the registry. + ''' + + def post(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle POST request. + ''' + try: + tag = REGISTRY.register( + action_class=self.get_argument('action-class'), + uid=self.get_argument('uid'), + alias=self.get_argument('alias', None), + parameter=self.get_argument('parameter', None), + ) + + data = { + 'success': True, + 'message': 'Tag successfully registered', + } + + data.update(tag.as_dict()) + + except ValueError as ex: + self.set_status(400) + data = { + 'success': False, + 'message': str(ex) + } + + self.set_header('Content-type', 'application/json') + self.write(dumps(data)) + + def put(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle PUT request. + ''' + self.post() + + +class UnregisterHandler(RequestHandler): # pylint: disable=abstract-method + ''' + Request handler which unregisters an RFID tag from the registry. + ''' + + def post(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle POST request. + ''' + try: + REGISTRY.unregister(uid=self.get_argument('uid')) + + data = { + 'success': True, + 'message': 'Tag successfully unregistered', + } + + except ValueError as ex: + self.set_status(400) + data = { + 'success': False, + 'message': str(ex) + } + + self.set_header('Content-type', 'application/json') + self.write(dumps(data)) + + def put(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle PUT request. + ''' + self.post() + + +class ActionClassesHandler(RequestHandler): # pylint: disable=abstract-method + ''' + Request handler which returns all action classes. + ''' + + def get(self, *args, **kwargs): # pylint: disable=unused-argument + ''' + Handle GET request. + ''' + data = { + 'success': True, + 'message': 'Action classes successfully retreived', + 'action_classes': ACTIONS + } + + self.set_header('Content-type', 'application/json') + self.write(dumps(data)) diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..a8f75af --- /dev/null +++ b/webui/index.html @@ -0,0 +1,42 @@ + + + + + + + + Pummeluff Tag Management + + +
+

Pummeluff Tag Management

+
+
+ + + + \ No newline at end of file diff --git a/webui/script.js b/webui/script.js new file mode 100644 index 0000000..14527da --- /dev/null +++ b/webui/script.js @@ -0,0 +1,188 @@ +/* + * API class which communicates with the Pummeluff REST API. + */ + +class API { + + /* + * Send AJAX request to REST API endpoint. + */ + + request = (endpoint, data, callback) => { + let init = {} + if(data) + init = { method: 'POST', body: data } + + fetch(endpoint, init) + .then((response) => { + return response.json() + }) + .then(callback) + + } + + /* + * Refresh the registry. + */ + + refreshRegistry = () => { + let callback = (response) => { + let tagsContainer = document.getElementById('tags') + while(tagsContainer.firstChild) { + tagsContainer.removeChild(tagsContainer.firstChild) + } + + for(let tag of response.tags) { + let tagElement = document.createElement('div') + tagElement.setAttribute('class', 'tag') + + let args = new Array('alias', 'uid', 'action_class', 'parameter') + for(let arg of args) { + let spanElement = document.createElement('span') + let value = tag[arg] ? tag[arg] : '-' + spanElement.setAttribute('class', arg.replace('_', '-')) + spanElement.innerHTML = value + tagElement.appendChild(spanElement) + } + + tagsContainer.appendChild(tagElement) + } + } + + this.request('/pummeluff/registry/', false, callback) + } + + /* + * Refresh the tags. + */ + + refreshActionClasses = () => { + let callback = (response) => { + let select = document.getElementById('action-class'); + while(select.firstChild) + select.removeChild(select.firstChild) + + for(let action_class in response.action_classes) { + let option = document.createElement('option') + option.setAttribute('value', action_class) + option.innerHTML = action_class + ' (' + response.action_classes[action_class] + ')' + select.appendChild(option) + } + } + + this.request('/pummeluff/action-classes/', false, callback) + } + + /* + * Reset the form. + */ + + formCallback = (response) => { + if(response.success) { + this.refreshRegistry() + document.getElementById('uid').value = '' + document.getElementById('alias').value = '' + document.getElementById('parameter').value = '' + document.getElementById('action-class').selectIndex = 0 + } else { + window.alert(response.message) + } + } + + /* + * Register a new tag. + */ + + register = () => { + let form = document.getElementById('register-form') + let data = new FormData(form) + this.request('/pummeluff/register/', data, this.formCallback) + } + + /* + * Unregister an existing tag. + */ + + unregister = () => { + let form = document.getElementById('register-form') + let data = new FormData(form) + this.request('/pummeluff/unregister/', data, this.formCallback) + } + + /* + * Get latest scanned tag. + */ + + getLatestTag = () => { + let latest_tag = undefined + + let uid_field = document.getElementById('uid') + let alias_field = document.getElementById('alias') + let parameter_field = document.getElementById('parameter') + let action_class_select = document.getElementById('action-class') + + uid_field.value = '' + alias_field.value = '' + parameter_field.value = '' + action_class_select.selectIndex = 0 + + let link = document.getElementById('read-rfid-tag') + link.classList.add('reading') + + let do_request = () => { + let callback = (response) => { + if(latest_tag && response.success && JSON.stringify(response) != JSON.stringify(latest_tag)) { + uid_field.value = response.uid + + if(response.alias) + alias_field.value = response.alias + + if(response.parameter) + parameter_field.value = response.parameter + + if(response.action_class) + action_class_select.value = response.action_class + + link.classList.remove('reading') + } else { + setTimeout(() => do_request(), 1000) + } + + latest_tag = response + } + + api.request('/pummeluff/latest/', false, callback) + } + + do_request() + } + +} + +api = new API() + +api.refreshRegistry() +api.refreshActionClasses() + +document.addEventListener('click', (event) => { + let target = event.target + let div = target.closest('div') + + if(div && div.classList.contains('tag')) { + for(let child of div.children) { + document.getElementById(child.className).value = child.innerHTML.replace(/^-$/, '') + } + } +}) + +document.getElementById('register-form').onsubmit = () => { + api.register() + return false; +} + +document.getElementById('unregister-button').onclick = () => { + api.unregister() + return false +} + +document.getElementById('read-rfid-tag').onclick = () => api.getLatestTag() diff --git a/webui/style.css b/webui/style.css new file mode 100644 index 0000000..15488dc --- /dev/null +++ b/webui/style.css @@ -0,0 +1,181 @@ +/* + * Main Elements + */ + +*, +*:before, +*:after +{ + box-sizing: border-box; +} + +html, +body +{ + margin : 0; + padding: 0; +} + +body +{ + background-color: #222; + color : #eee; + font-family : sans-serif; + font-size : 14px; + min-height : 100vh; +} + +/* + * Headings + */ + +h1, +h2 +{ + margin: 0 0 10px 0; +} + +/* + * Structural page elements + */ + +main +{ + display: flex; +} + +header +{ + padding: 20px 20px 10px 20px; +} + +main > div +{ + padding: 20px; +} + +/* + * Register Form + */ + +form +{ + background-color: #333; + padding : 20px; + width : 250px; +} + +label +{ + display: block; + margin : 10px 0 5px 0; +} + +label:first-child +{ + margin-top: 0; +} + +input, +select, +button +{ + background-color: #222; + border : 0; + border-color : #333; + border-style : solid; + border-width : 0 0 2px 0; + color : #eee; + outline : none; + padding : 10px; + width : 100%; +} + +select +{ + height: 35px; +} + +input::placeholder +{ + color: #666; +} + +input:focus +{ + border-bottom-color: #8ff; +} + +button +{ + color : #eee; + cursor : pointer; + font-weight: bold; + margin-top : 10px; +} + +button#register-button +{ + background-color: #4a4; +} + +button#unregister-button +{ + background-color: #a20; +} + +#read-rfid-tag +{ + color : #8ff; + font-size : 11px; + text-decoration: none; +} + +#read-rfid-tag.reading { + animation: blink 0.5s cubic-bezier(.5, 0, 1, 1) infinite alternate; +} + +@keyframes blink { to { opacity: 0.25; } } + +/* + * Registered Tags + */ + +div.tag +{ + background-color: #eee; + box-shadow : 1px 1px 5px #000; + color : #222; + cursor : pointer; + display : inline-block; + line-height : 20px; + margin : 0 20px 20px 0; + padding : 10px; + width : 400px; +} + +div.tag span.uid, +div.tag span.action-class, +div.tag span.parameter +{ + font-family: Courier New, monospace; +} + +div.tag span.action-class +{ + background-color: #888; + border-radius : 10px; + color : #eee; + display : inline-block; + font-size : 11px; + line-height : 11px; + margin-left : 5px; + padding : 3px 5px; +} + +div.tag span.alias, +div.tag span.parameter +{ + display: block; +} +
+
+

Register New Tag

+
+
+ + + Read UID from RFID tag… + + + + + + + + +
+
+
+
+

Registered Tags

+
+
+