commit 73c6297ac6bf6ecdbcf10b1ef389da1fe315b56b Author: Tobias Brunner Date: Sun Nov 28 14:15:58 2021 +0000 tobru mods 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 0000000..5f7727b Binary files /dev/null and b/sounds/fail.wav differ diff --git a/sounds/success.wav b/sounds/success.wav new file mode 100644 index 0000000..1862a36 Binary files /dev/null and b/sounds/success.wav differ diff --git a/threads/__init__.py b/threads/__init__.py new file mode 100644 index 0000000..b68752a --- /dev/null +++ b/threads/__init__.py @@ -0,0 +1,6 @@ +''' +Threads of Mopidy Pummeluff. +''' + +from .gpio_handler import * +from .tag_reader import * diff --git a/threads/gpio_handler.py b/threads/gpio_handler.py new file mode 100644 index 0000000..873287b --- /dev/null +++ b/threads/gpio_handler.py @@ -0,0 +1,84 @@ +''' +Python module for the dedicated Mopidy Pummeluff threads. +''' + +__all__ = ( + 'GPIOHandler', +) + +from threading import Thread +from logging import getLogger +from time import time + +import RPi.GPIO as GPIO + +from mopidy_pummeluff.actions import Shutdown, PlayPause, Stop, PreviousTrack, NextTrack +from mopidy_pummeluff.sound import play_sound + +LOGGER = getLogger(__name__) + + +class GPIOHandler(Thread): + ''' + Thread which handles the GPIO ports, which basically means activating the + LED when it's started and then reacting to button presses. + ''' + button_pins = { + #5: Shutdown, + #29: PlayPause, + #31: Stop, + #33: PreviousTrack, + 35: NextTrack, + } + + led_pin = 8 + + 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 + + now = time() + self.timestamps = {x: now for x in self.button_pins} + + # pylint: disable=no-member + def run(self): + ''' + Run the thread. + ''' + GPIO.setmode(GPIO.BOARD) + + for pin in self.button_pins: + LOGGER.debug('Setup pin %s as button pin', pin) + GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_UP) + GPIO.add_event_detect(pin, GPIO.RISING, callback=lambda pin: self.button_push(pin)) # pylint: disable=unnecessary-lambda + + LOGGER.debug('Setup pin %s as LED pin', self.led_pin) + GPIO.setup(self.led_pin, GPIO.OUT) + GPIO.output(self.led_pin, GPIO.HIGH) + play_sound('success.wav') + + self.stop_event.wait() + GPIO.cleanup() # pylint: disable=no-member + + def button_push(self, pin): + ''' + Callback method when a button is pushed. + + :param int pin: Pin number + ''' + now = time() + before = self.timestamps[pin] + + if (GPIO.input(pin) == GPIO.LOW) and (now - before > 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

+
+
+
+

Register New Tag

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

Registered Tags

+
+
+
+ + + + \ 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; +} +