Added NFC backend
This commit is contained in:
parent
0a97bb2345
commit
24d3810e44
6 changed files with 317 additions and 47 deletions
|
@ -207,6 +207,8 @@ autodoc_mock_imports = ['googlesamples.assistant.grpc.audio_helpers',
|
||||||
'soundfile',
|
'soundfile',
|
||||||
'numpy',
|
'numpy',
|
||||||
'cv2',
|
'cv2',
|
||||||
|
'nfc',
|
||||||
|
'ndef',
|
||||||
]
|
]
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath('../..'))
|
sys.path.insert(0, os.path.abspath('../..'))
|
||||||
|
|
187
platypush/backend/nfc.py
Normal file
187
platypush/backend/nfc.py
Normal file
|
@ -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:
|
80
platypush/message/event/nfc.py
Normal file
80
platypush/message/event/nfc.py
Normal file
|
@ -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:
|
|
@ -5,7 +5,6 @@ import logging
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
|
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
|
@ -37,36 +36,31 @@ class Request(Message):
|
||||||
|
|
||||||
super().__init__(timestamp=timestamp)
|
super().__init__(timestamp=timestamp)
|
||||||
|
|
||||||
self.id = id if id else self._generate_id()
|
self.id = id if id else self._generate_id()
|
||||||
self.target = target
|
self.target = target
|
||||||
self.action = action
|
self.action = action
|
||||||
self.origin = origin
|
self.origin = origin
|
||||||
self.args = args if args else {}
|
self.args = args if args else {}
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.token = token
|
self.token = token
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build(cls, msg):
|
def build(cls, msg):
|
||||||
msg = super().parse(msg)
|
msg = super().parse(msg)
|
||||||
args = {
|
args = {'target': msg.get('target', Config.get('device_id')), 'action': msg['action'],
|
||||||
'target' : msg.get('target', Config.get('device_id')),
|
'args': msg.get('args', {}), 'id': msg['id'] if 'id' in msg else cls._generate_id(),
|
||||||
'action' : msg['action'],
|
'timestamp': msg['_timestamp'] if '_timestamp' in msg else time.time()}
|
||||||
'args' : msg['args'] if 'args' in msg else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 'origin' in msg: args['origin'] = msg['origin']
|
||||||
if 'token' in msg: args['token'] = msg['token']
|
if 'token' in msg: args['token'] = msg['token']
|
||||||
return cls(**args)
|
return cls(**args)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_id():
|
def _generate_id():
|
||||||
id = ''
|
_id = ''
|
||||||
for i in range(0,16):
|
for i in range(0, 16):
|
||||||
id += '%.2x' % random.randint(0, 255)
|
_id += '%.2x' % random.randint(0, 255)
|
||||||
return id
|
return _id
|
||||||
|
|
||||||
|
|
||||||
def _execute_procedure(self, *args, **kwargs):
|
def _execute_procedure(self, *args, **kwargs):
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
|
@ -81,7 +75,6 @@ class Request(Message):
|
||||||
|
|
||||||
return proc.execute(*args, **kwargs)
|
return proc.execute(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _expand_context(self, event_args=None, **context):
|
def _expand_context(self, event_args=None, **context):
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
|
|
||||||
|
@ -110,7 +103,6 @@ class Request(Message):
|
||||||
|
|
||||||
return event_args
|
return event_args
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def expand_value_from_context(cls, _value, **context):
|
def expand_value_from_context(cls, _value, **context):
|
||||||
for (k, v) in context.items():
|
for (k, v) in context.items():
|
||||||
|
@ -130,10 +122,12 @@ class Request(Message):
|
||||||
parsed_value = _value
|
parsed_value = _value
|
||||||
|
|
||||||
while _value and isinstance(_value, str):
|
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('\\'):
|
if m and not m.group(1).endswith('\\'):
|
||||||
prefix = m.group(1); expr = m.group(2);
|
prefix = m.group(1)
|
||||||
inner_expr = m.group(3); _value = m.group(4)
|
expr = m.group(2)
|
||||||
|
inner_expr = m.group(3)
|
||||||
|
_value = m.group(4)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context_value = eval(inner_expr)
|
context_value = eval(inner_expr)
|
||||||
|
@ -155,9 +149,10 @@ class Request(Message):
|
||||||
parsed_value += _value
|
parsed_value += _value
|
||||||
_value = ''
|
_value = ''
|
||||||
|
|
||||||
try: return json.loads(parsed_value)
|
try:
|
||||||
except Exception as e: return parsed_value
|
return json.loads(parsed_value)
|
||||||
|
except:
|
||||||
|
return parsed_value
|
||||||
|
|
||||||
def _send_response(self, response):
|
def _send_response(self, response):
|
||||||
response = Response.build(response)
|
response = Response.build(response)
|
||||||
|
@ -174,7 +169,6 @@ class Request(Message):
|
||||||
redis.send_message(queue_name, response)
|
redis.send_message(queue_name, response)
|
||||||
redis.expire(queue_name, 60)
|
redis.expire(queue_name, 60)
|
||||||
|
|
||||||
|
|
||||||
def execute(self, n_tries=1, _async=True, **context):
|
def execute(self, n_tries=1, _async=True, **context):
|
||||||
"""
|
"""
|
||||||
Execute this request and returns a Response object
|
Execute this request and returns a Response object
|
||||||
|
@ -190,44 +184,48 @@ class Request(Message):
|
||||||
- group: ${group_name} # will be expanded as "Kitchen lights")
|
- 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.'):
|
if self.action.startswith('procedure.'):
|
||||||
context['n_tries'] = n_tries
|
context['n_tries'] = _n_tries
|
||||||
response = self._execute_procedure(**context)
|
response = self._execute_procedure(**context)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
self._send_response(response)
|
self._send_response(response)
|
||||||
return response
|
return response
|
||||||
else:
|
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)
|
plugin = get_plugin(module_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the action
|
# Run the action
|
||||||
args = self._expand_context(**context)
|
args = self._expand_context(**context)
|
||||||
|
args = self.expand_value_from_context(args, **context)
|
||||||
response = plugin.run(method=method_name, **args)
|
response = plugin.run(method=method_name, **args)
|
||||||
|
|
||||||
if response and response.is_error():
|
if response and response.is_error():
|
||||||
logger.warning(('Response processed with errors from ' +
|
logger.warning(('Response processed with errors from ' +
|
||||||
'action {}: {}').format(
|
'action {}: {}').format(
|
||||||
self.action, str(response)))
|
action, str(response)))
|
||||||
elif not response.disable_logging:
|
elif not response.disable_logging:
|
||||||
logger.info('Processed response from action {}: {}'.
|
logger.info('Processed response from action {}: {}'.
|
||||||
format(self.action, str(response)))
|
format(action, str(response)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Retry mechanism
|
# Retry mechanism
|
||||||
plugin.logger.exception(e)
|
plugin.logger.exception(e)
|
||||||
logger.warning(('Uncaught exception while processing response ' +
|
logger.warning(('Uncaught exception while processing response ' +
|
||||||
'from action [{}]: {}').format(self.action, str(e)))
|
'from action [{}]: {}').format(action, str(e)))
|
||||||
|
|
||||||
errors = errors or []
|
errors = errors or []
|
||||||
if str(e) not in errors:
|
if str(e) not in errors:
|
||||||
errors.append(str(e))
|
errors.append(str(e))
|
||||||
|
|
||||||
response = Response(output=None, errors=errors)
|
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))
|
logger.info('Reloading plugin {} and retrying'.format(module_name))
|
||||||
get_plugin(module_name, reload=True)
|
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:
|
finally:
|
||||||
self._send_response(response)
|
self._send_response(response)
|
||||||
return response
|
return response
|
||||||
|
@ -243,7 +241,6 @@ class Request(Message):
|
||||||
else:
|
else:
|
||||||
return _thread_func(n_tries)
|
return _thread_func(n_tries)
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""
|
"""
|
||||||
Overrides the str() operator and converts
|
Overrides the str() operator and converts
|
||||||
|
@ -251,16 +248,15 @@ class Request(Message):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
'type' : 'request',
|
'type': 'request',
|
||||||
'target' : self.target,
|
'target': self.target,
|
||||||
'action' : self.action,
|
'action': self.action,
|
||||||
'args' : self.args,
|
'args': self.args,
|
||||||
'origin' : self.origin if hasattr(self, 'origin') else None,
|
'origin': self.origin if hasattr(self, 'origin') else None,
|
||||||
'id' : self.id if hasattr(self, 'id') else None,
|
'id': self.id if hasattr(self, 'id') else None,
|
||||||
'token' : self.token if hasattr(self, 'token') else None,
|
'token': self.token if hasattr(self, 'token') else None,
|
||||||
'_timestamp' : self.timestamp,
|
'_timestamp': self.timestamp,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
||||||
|
|
|
@ -133,8 +133,12 @@ inputs
|
||||||
websocket-client
|
websocket-client
|
||||||
|
|
||||||
# mpv player plugin
|
# mpv player plugin
|
||||||
python-mpv
|
# python-mpv
|
||||||
|
|
||||||
# SCSS/SASS to CSS compiler for web pages style
|
# SCSS/SASS to CSS compiler for web pages style
|
||||||
pyScss
|
pyScss
|
||||||
|
|
||||||
|
# Support for NFC tags
|
||||||
|
# nfcpy >= 1.0
|
||||||
|
# ndef
|
||||||
|
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -169,6 +169,7 @@ setup(
|
||||||
'Support for mopidy backend': ['websocket-client'],
|
'Support for mopidy backend': ['websocket-client'],
|
||||||
'Support for mpv player plugin': ['python-mpv'],
|
'Support for mpv player plugin': ['python-mpv'],
|
||||||
'Support for compiling SASS/SCSS styles to CSS': ['pyScss'],
|
'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 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 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']
|
# 'Support for media subtitles': ['git+https://github.com/agonzalezro/python-opensubtitles#egg=python-opensubtitles']
|
||||||
|
|
Loading…
Reference in a new issue