diff --git a/docs/source/conf.py b/docs/source/conf.py index 0b053953..bb1879e7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -207,6 +207,8 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers', 'soundfile', 'numpy', 'cv2', + 'nfc', + 'ndef', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/backend/nfc.py b/platypush/backend/nfc.py new file mode 100644 index 00000000..e946dbb6 --- /dev/null +++ b/platypush/backend/nfc.py @@ -0,0 +1,187 @@ +import base64 +import json +import nfc +import ndef + +from platypush.backend import Backend +from platypush.message.event.nfc import NFCTagDetectedEvent, NFCTagRemovedEvent, NFCDeviceConnectedEvent, \ + NFCDeviceDisconnectedEvent + + +class NfcBackend(Backend): + """ + Backend to detect NFC card events from a compatible reader. + + Triggers: + + * :class:`platypush.message.event.nfc.NFCDeviceConnectedEvent` when an NFC reader/writer is connected + * :class:`platypush.message.event.nfc.NFCDeviceDisconnectedEvent` when an NFC reader/writer is disconnected + * :class:`platypush.message.event.nfc.NFCTagDetectedEvent` when an NFC tag is detected + * :class:`platypush.message.event.nfc.NFCTagRemovedEvent` when an NFC tag is removed + + Requires: + + * **nfcpy** >= 1.0 (``pip install nfcpy``) + """ + + def __init__(self, device='usb', *args, **kwargs): + """ + :param device: Address or ID of the device to be opened. Examples: + + * `'usb:003:009'` opens device 9 on bus 3 + * `'usb:003'` opens the first available device on bus 3 + * `'usb'` opens the first available USB device (default) + """ + + super().__init__(*args, **kwargs) + + self.device_id = device + self._clf = None + + def _get_clf(self): + if not self._clf: + self._clf = nfc.ContactlessFrontend() + self._clf.open(self.device_id) + self.bus.post(NFCDeviceConnectedEvent(reader=self._get_device_str())) + self.logger.info('Initialized NFC reader backend on device {}'.format(self._get_device_str())) + + return self._clf + + def _get_device_str(self): + return str(self._clf.device) + + def close(self): + if self._clf: + self._clf.close() + self._clf = None + self.bus.post(NFCDeviceDisconnectedEvent(reader=self._get_device_str())) + + @staticmethod + def _parse_records(tag): + records = [] + + for record in tag.ndef.records: + r = { + 'record_type': record.type, + 'record_name': record.name, + } + + if isinstance(record, ndef.TextRecord): + try: + r = { + **r, + 'type': 'json', + 'value': json.loads(record.text), + } + except ValueError: + r = { + **r, + 'type': 'text', + 'text': record.text, + } + elif isinstance(record, ndef.UriRecord): + r = { + **r, + 'type': 'uri', + 'uri': record.uri, + 'iri': record.iri, + } + elif isinstance(record, ndef.SmartposterRecord): + r = { + **r, + 'type': 'smartposter', + **{attr: getattr(record, attr) for attr in ['resource', 'titles', 'title', 'action', 'icon', + 'icons', 'resource_size', 'resource_type']}, + } + elif isinstance(record, ndef.DeviceInformationRecord): + r = { + **r, + 'type': 'device_info', + **{attr: getattr(record, attr) for attr in ['vendor_name', 'model_name', 'unique_name', + 'uuid_string', 'version_string']}, + } + elif isinstance(record, ndef.WifiSimpleConfigRecord): + r = { + **r, + 'type': 'wifi_simple_config', + **{attr: record[attr] for attr in record.attribute_names()} + } + elif isinstance(record, ndef.WifiPeerToPeerRecord): + r = { + **r, + 'type': 'wifi_peer_to_peer', + **{attr: record[attr] for attr in record.attribute_names()} + } + elif isinstance(record, ndef.BluetoothEasyPairingRecord): + r = { + **r, + 'type': 'bluetooth_easy_pairing', + **{attr: getattr(record, attr) for attr in ['device_address', 'device_name', 'device_class']}, + } + elif isinstance(record, ndef.BluetoothLowEnergyRecord): + r = { + **r, + 'type': 'bluetooth_low_energy', + **{attr: getattr(record, attr) for attr in ['device_address', 'device_name', 'role_capabilities', + 'appearance', 'flags', 'security_manager_tk_value', + 'secure_connections_confirmation_value', + 'secure_connections_random_value']}, + } + elif isinstance(record, ndef.SignatureRecord): + r = { + **r, + 'type': 'signature', + **{attr: getattr(record, attr) for attr in ['version', 'signature_type', 'hash_type', 'signature', + 'signature_uri', 'certificate_format', + 'certificate_store', 'certificate_uri', + 'secure_connections_random_value']}, + } + else: + r = { + **r, + 'type': 'binary', + 'data': base64.encodebytes(record.data).decode(), + } + + records.append(r) + + return records + + @staticmethod + def _parse_id(tag): + return ''.join([('%02X' % c) for c in tag.identifier]) + + def _on_connect(self): + def callback(tag): + if not tag: + return False + + tag_id = self._parse_id(tag) + records = self._parse_records(tag) + self.bus.post(NFCTagDetectedEvent(reader=self._get_device_str(), tag_id=tag_id, records=records)) + return True + + return callback + + def _on_release(self): + def callback(tag): + tag_id = self._parse_id(tag) + self.bus.post(NFCTagRemovedEvent(reader=self._get_device_str(), tag_id=tag_id)) + + return callback + + def run(self): + super().run() + + while not self.should_stop(): + try: + clf = self._get_clf() + clf.connect(rdwr={ + 'on-connect': self._on_connect(), + 'on-release': self._on_release(), + }) + finally: + self.close() + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/event/nfc.py b/platypush/message/event/nfc.py new file mode 100644 index 00000000..094432e9 --- /dev/null +++ b/platypush/message/event/nfc.py @@ -0,0 +1,80 @@ +import json + +from platypush.message.event import Event + + +class NFCEvent(Event): + """ + Generic class for NFC events + """ + + def __init__(self, reader=None, tag_id=None, *args, **kwargs): + super().__init__(reader=reader, tag_id=tag_id, *args, **kwargs) + + +class NFCDeviceConnectedEvent(NFCEvent): + """ + Event triggered when an NFC reader/writer devices is connected + """ + + def __init__(self, reader=None, *args, **kwargs): + """ + :param reader: Name or address of the reader that fired the event + :type reader: str + """ + super().__init__(reader=reader, *args, **kwargs) + + +class NFCDeviceDisconnectedEvent(NFCEvent): + """ + Event triggered when an NFC reader/writer devices is disconnected + """ + + def __init__(self, reader=None, *args, **kwargs): + """ + :param reader: Name or address of the reader that fired the event + :type reader: str + """ + super().__init__(reader=reader, *args, **kwargs) + + +class NFCTagDetectedEvent(NFCEvent): + """ + Event triggered when an NFC tag is connected + """ + + def __init__(self, reader=None, tag_id=None, records=None, *args, **kwargs): + """ + :param reader: Name or address of the reader that fired the event + :type reader: str + + :param tag_id: ID of the NFC tag + :type tag_id: str + + :param records: Optional, list of records read from the tag. If the tag contains JSON-serializable data then it + will be cast by the backend into the appropriate object + :type records: str, bytes or JSON-serializable object + """ + if not records: + records = [] + + super().__init__(reader=reader, tag_id=tag_id, records=records, *args, **kwargs) + + +class NFCTagRemovedEvent(NFCEvent): + """ + Event triggered when a NFC card is removed/disconnected + """ + + def __init__(self, reader=None, tag_id=None, *args, **kwargs): + """ + :param reader: Name or address of the reader that fired the event + :type reader: str + + :param tag_id: ID of the NFC tag + :type tag_id: str + """ + super().__init__(reader=reader, tag_id=tag_id, *args, **kwargs) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py index e93469cb..9e3365a5 100644 --- a/platypush/message/request/__init__.py +++ b/platypush/message/request/__init__.py @@ -5,7 +5,6 @@ import logging import random import re import time -import traceback from threading import Thread @@ -37,36 +36,31 @@ class Request(Message): super().__init__(timestamp=timestamp) - self.id = id if id else self._generate_id() - self.target = target - self.action = action - self.origin = origin - self.args = args if args else {} + self.id = id if id else self._generate_id() + self.target = target + self.action = action + self.origin = origin + self.args = args if args else {} self.backend = backend - self.token = token + self.token = token @classmethod def build(cls, msg): msg = super().parse(msg) - args = { - 'target' : msg.get('target', Config.get('device_id')), - 'action' : msg['action'], - 'args' : msg['args'] if 'args' in msg else {}, - } + args = {'target': msg.get('target', Config.get('device_id')), 'action': msg['action'], + 'args': msg.get('args', {}), 'id': msg['id'] if 'id' in msg else cls._generate_id(), + 'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time()} - args['id'] = msg['id'] if 'id' in msg else cls._generate_id() - args['timestamp'] = msg['_timestamp'] if '_timestamp' in msg else time.time() if 'origin' in msg: args['origin'] = msg['origin'] if 'token' in msg: args['token'] = msg['token'] return cls(**args) @staticmethod def _generate_id(): - id = '' - for i in range(0,16): - id += '%.2x' % random.randint(0, 255) - return id - + _id = '' + for i in range(0, 16): + _id += '%.2x' % random.randint(0, 255) + return _id def _execute_procedure(self, *args, **kwargs): from platypush.config import Config @@ -81,7 +75,6 @@ class Request(Message): return proc.execute(*args, **kwargs) - def _expand_context(self, event_args=None, **context): from platypush.config import Config @@ -110,7 +103,6 @@ class Request(Message): return event_args - @classmethod def expand_value_from_context(cls, _value, **context): for (k, v) in context.items(): @@ -130,10 +122,12 @@ class Request(Message): parsed_value = _value while _value and isinstance(_value, str): - m = re.match('([^\$]*)(\${\s*(.+?)\s*})(.*)', _value) + m = re.match('([^$]*)(\${\s*(.+?)\s*})(.*)', _value) if m and not m.group(1).endswith('\\'): - prefix = m.group(1); expr = m.group(2); - inner_expr = m.group(3); _value = m.group(4) + prefix = m.group(1) + expr = m.group(2) + inner_expr = m.group(3) + _value = m.group(4) try: context_value = eval(inner_expr) @@ -155,9 +149,10 @@ class Request(Message): parsed_value += _value _value = '' - try: return json.loads(parsed_value) - except Exception as e: return parsed_value - + try: + return json.loads(parsed_value) + except: + return parsed_value def _send_response(self, response): response = Response.build(response) @@ -174,7 +169,6 @@ class Request(Message): redis.send_message(queue_name, response) redis.expire(queue_name, 60) - def execute(self, n_tries=1, _async=True, **context): """ Execute this request and returns a Response object @@ -190,44 +184,48 @@ class Request(Message): - group: ${group_name} # will be expanded as "Kitchen lights") """ - def _thread_func(n_tries, errors=None): + def _thread_func(_n_tries, errors=None): + response = None + if self.action.startswith('procedure.'): - context['n_tries'] = n_tries + context['n_tries'] = _n_tries response = self._execute_procedure(**context) if response is not None: self._send_response(response) return response else: - (module_name, method_name) = get_module_and_method_from_action(self.action) + action = self.expand_value_from_context(self.action, **context) + (module_name, method_name) = get_module_and_method_from_action(action) plugin = get_plugin(module_name) try: # Run the action args = self._expand_context(**context) + args = self.expand_value_from_context(args, **context) response = plugin.run(method=method_name, **args) if response and response.is_error(): logger.warning(('Response processed with errors from ' + 'action {}: {}').format( - self.action, str(response))) + action, str(response))) elif not response.disable_logging: logger.info('Processed response from action {}: {}'. - format(self.action, str(response))) + format(action, str(response))) except Exception as e: # Retry mechanism plugin.logger.exception(e) logger.warning(('Uncaught exception while processing response ' + - 'from action [{}]: {}').format(self.action, str(e))) + 'from action [{}]: {}').format(action, str(e))) errors = errors or [] if str(e) not in errors: errors.append(str(e)) response = Response(output=None, errors=errors) - if n_tries-1 > 0: + if _n_tries-1 > 0: logger.info('Reloading plugin {} and retrying'.format(module_name)) get_plugin(module_name, reload=True) - response = _thread_func(n_tries=n_tries-1, errors=errors) + response = _thread_func(_n_tries=_n_tries - 1, errors=errors) finally: self._send_response(response) return response @@ -243,7 +241,6 @@ class Request(Message): else: return _thread_func(n_tries) - def __str__(self): """ Overrides the str() operator and converts @@ -251,16 +248,15 @@ class Request(Message): """ return json.dumps({ - 'type' : 'request', - 'target' : self.target, - 'action' : self.action, - 'args' : self.args, - 'origin' : self.origin if hasattr(self, 'origin') else None, - 'id' : self.id if hasattr(self, 'id') else None, - 'token' : self.token if hasattr(self, 'token') else None, - '_timestamp' : self.timestamp, + 'type': 'request', + 'target': self.target, + 'action': self.action, + 'args': self.args, + 'origin': self.origin if hasattr(self, 'origin') else None, + 'id': self.id if hasattr(self, 'id') else None, + 'token': self.token if hasattr(self, 'token') else None, + '_timestamp': self.timestamp, }) # vim:sw=4:ts=4:et: - diff --git a/requirements.txt b/requirements.txt index 0e736534..ca7798e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -133,8 +133,12 @@ inputs websocket-client # mpv player plugin -python-mpv +# python-mpv # SCSS/SASS to CSS compiler for web pages style pyScss +# Support for NFC tags +# nfcpy >= 1.0 +# ndef + diff --git a/setup.py b/setup.py index 5f4b7bf0..a344d77e 100755 --- a/setup.py +++ b/setup.py @@ -169,6 +169,7 @@ setup( 'Support for mopidy backend': ['websocket-client'], 'Support for mpv player plugin': ['python-mpv'], 'Support for compiling SASS/SCSS styles to CSS': ['pyScss'], + 'Support for NFC tags': ['nfcpy>=1.0', 'ndef'], # 'Support for Leap Motion backend': ['git+ssh://git@github.com:BlackLight/leap-sdk-python3.git'], # 'Support for Flic buttons': ['git+https://@github.com/50ButtonsEach/fliclib-linux-hci.git'] # 'Support for media subtitles': ['git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles']