diff --git a/docs/source/conf.py b/docs/source/conf.py index 900c617c..b1b9a0a6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -304,6 +304,7 @@ autodoc_mock_imports = [ 'TheengsDecoder', 'simple_websocket', 'uvicorn', + 'websockets', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/backend/assistant/google/__init__.py b/platypush/backend/assistant/google/__init__.py index 0b763e4e..1fe7fb31 100644 --- a/platypush/backend/assistant/google/__init__.py +++ b/platypush/backend/assistant/google/__init__.py @@ -8,11 +8,22 @@ import os import time from platypush.backend.assistant import AssistantBackend -from platypush.message.event.assistant import \ - ConversationStartEvent, ConversationEndEvent, ConversationTimeoutEvent, \ - ResponseEvent, NoResponseEvent, SpeechRecognizedEvent, AlarmStartedEvent, \ - AlarmEndEvent, TimerStartedEvent, TimerEndEvent, AlertStartedEvent, \ - AlertEndEvent, MicMutedEvent, MicUnmutedEvent +from platypush.message.event.assistant import ( + ConversationStartEvent, + ConversationEndEvent, + ConversationTimeoutEvent, + ResponseEvent, + NoResponseEvent, + SpeechRecognizedEvent, + AlarmStartedEvent, + AlarmEndEvent, + TimerStartedEvent, + TimerEndEvent, + AlertStartedEvent, + AlertEndEvent, + MicMutedEvent, + MicUnmutedEvent, +) class AssistantGoogleBackend(AssistantBackend): @@ -57,22 +68,30 @@ class AssistantGoogleBackend(AssistantBackend): * **google-assistant-library** (``pip install google-assistant-library``) * **google-assistant-sdk[samples]** (``pip install google-assistant-sdk[samples]``) + * **google-auth** (``pip install google-auth``) + """ - def __init__(self, - credentials_file=os.path.join( - os.path.expanduser('~/.config'), - 'google-oauthlib-tool', 'credentials.json'), - device_model_id='Platypush', **kwargs): + _default_credentials_file = os.path.join( + os.path.expanduser('~/.config'), 'google-oauthlib-tool', 'credentials.json' + ) + + def __init__( + self, + credentials_file=_default_credentials_file, + device_model_id='Platypush', + **kwargs + ): """ - :param credentials_file: Path to the Google OAuth credentials file \ - (default: ~/.config/google-oauthlib-tool/credentials.json). \ - See https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials \ + :param credentials_file: Path to the Google OAuth credentials file + (default: ~/.config/google-oauthlib-tool/credentials.json). + See + https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials for instructions to get your own credentials file. :type credentials_file: str - :param device_model_id: Device model ID to use for the assistant \ + :param device_model_id: Device model ID to use for the assistant (default: Platypush) :type device_model_id: str """ @@ -102,17 +121,23 @@ class AssistantGoogleBackend(AssistantBackend): self.bus.post(ConversationTimeoutEvent(assistant=self)) elif event.type == EventType.ON_NO_RESPONSE: self.bus.post(NoResponseEvent(assistant=self)) - elif hasattr(EventType, 'ON_RENDER_RESPONSE') and \ - event.type == EventType.ON_RENDER_RESPONSE: - self.bus.post(ResponseEvent(assistant=self, response_text=event.args.get('text'))) + elif ( + hasattr(EventType, 'ON_RENDER_RESPONSE') + and event.type == EventType.ON_RENDER_RESPONSE + ): + self.bus.post( + ResponseEvent(assistant=self, response_text=event.args.get('text')) + ) tts, args = self._get_tts_plugin() if tts and 'text' in event.args: self.stop_conversation() tts.say(text=event.args['text'], **args) - elif hasattr(EventType, 'ON_RESPONDING_STARTED') and \ - event.type == EventType.ON_RESPONDING_STARTED and \ - event.args.get('is_error_response', False) is True: + elif ( + hasattr(EventType, 'ON_RESPONDING_STARTED') + and event.type == EventType.ON_RESPONDING_STARTED + and event.args.get('is_error_response', False) is True + ): self.logger.warning('Assistant response error') elif event.type == EventType.ON_RECOGNIZING_SPEECH_FINISHED: phrase = event.args['text'].lower().strip() @@ -144,12 +169,12 @@ class AssistantGoogleBackend(AssistantBackend): self.bus.post(event) def start_conversation(self): - """ Starts an assistant conversation """ + """Starts an assistant conversation""" if self.assistant: self.assistant.start_conversation() def stop_conversation(self): - """ Stops an assistant conversation """ + """Stops an assistant conversation""" if self.assistant: self.assistant.stop_conversation() @@ -177,7 +202,9 @@ class AssistantGoogleBackend(AssistantBackend): super().run() with open(self.credentials_file, 'r') as f: - self.credentials = google.oauth2.credentials.Credentials(token=None, **json.load(f)) + self.credentials = google.oauth2.credentials.Credentials( + token=None, **json.load(f) + ) while not self.should_stop(): self._has_error = False @@ -186,12 +213,16 @@ class AssistantGoogleBackend(AssistantBackend): self.assistant = assistant for event in assistant.start(): if not self.is_detecting(): - self.logger.info('Assistant event received but detection is currently paused') + self.logger.info( + 'Assistant event received but detection is currently paused' + ) continue self._process_event(event) if self._has_error: - self.logger.info('Restarting the assistant after an unrecoverable error') + self.logger.info( + 'Restarting the assistant after an unrecoverable error' + ) time.sleep(5) break diff --git a/platypush/backend/assistant/google/manifest.yaml b/platypush/backend/assistant/google/manifest.yaml index 1668b511..410f231c 100644 --- a/platypush/backend/assistant/google/manifest.yaml +++ b/platypush/backend/assistant/google/manifest.yaml @@ -22,5 +22,6 @@ manifest: pip: - google-assistant-library - google-assistant-sdk[samples] + - google-auth package: platypush.backend.assistant.google type: backend diff --git a/platypush/backend/camera/pi/manifest.yaml b/platypush/backend/camera/pi/manifest.yaml index b48cf3c6..793fcadd 100644 --- a/platypush/backend/camera/pi/manifest.yaml +++ b/platypush/backend/camera/pi/manifest.yaml @@ -3,5 +3,7 @@ manifest: install: pip: - picamera + - numpy + - Pillow package: platypush.backend.camera.pi type: backend diff --git a/platypush/backend/http/__init__.py b/platypush/backend/http/__init__.py index 6fc4dab1..922ad781 100644 --- a/platypush/backend/http/__init__.py +++ b/platypush/backend/http/__init__.py @@ -3,13 +3,17 @@ import pathlib import secrets import threading -from multiprocessing import Process, cpu_count +from multiprocessing import Process from typing import Mapping, Optional +from tornado.wsgi import WSGIContainer +from tornado.web import Application, FallbackHandler +from tornado.ioloop import IOLoop + from platypush.backend import Backend from platypush.backend.http.app import application -from platypush.backend.http.ws import events_redis_topic -from platypush.backend.http.wsgi import WSGIApplicationWrapper +from platypush.backend.http.ws import WSEventProxy, events_redis_topic + from platypush.bus.redis import RedisBus from platypush.config import Config from platypush.utils import get_redis @@ -177,6 +181,7 @@ class HttpBackend(Backend): self.server_proc = None self._service_registry_thread = None self.bind_address = bind_address + self._io_loop: Optional[IOLoop] = None if resource_dirs: self.resource_dirs = { @@ -200,6 +205,10 @@ class HttpBackend(Backend): super().on_stop() self.logger.info('Received STOP event on HttpBackend') + if self._io_loop: + self._io_loop.stop() + self._io_loop.close() + if self.server_proc: self.server_proc.terminate() self.server_proc.join(timeout=10) @@ -216,6 +225,8 @@ class HttpBackend(Backend): self._service_registry_thread.join(timeout=5) self._service_registry_thread = None + self._io_loop = None + def notify_web_clients(self, event): """Notify all the connected web clients (over websocket) of a new event""" get_redis().publish(events_redis_topic, str(event)) @@ -248,14 +259,23 @@ class HttpBackend(Backend): application.config['redis_queue'] = self.bus.redis_queue application.secret_key = self._get_secret_key() - kwargs = { - 'bind': f'{self.bind_address}:{self.port}', - 'workers': (cpu_count() * 2) + 1, - 'worker_class_str': f'{__package__}.app.UvicornWorker', - 'timeout': 30, - } - WSGIApplicationWrapper(f'{__package__}.app:application', kwargs).run() + container = WSGIContainer(application) + server = Application( + [ + (r'/ws/events', WSEventProxy), + (r'.*', FallbackHandler, {'fallback': container}), + ] + ) + + server.listen(address=self.bind_address, port=self.port) + self._io_loop = IOLoop.instance() + + try: + self._io_loop.start() + except Exception as e: + if not self.should_stop(): + raise e return proc diff --git a/platypush/backend/http/app/routes/websocket.py b/platypush/backend/http/app/routes/websocket.py deleted file mode 100644 index d0bc4be3..00000000 --- a/platypush/backend/http/app/routes/websocket.py +++ /dev/null @@ -1,65 +0,0 @@ -from logging import getLogger - -from flask import Blueprint, request -from simple_websocket import ConnectionClosed, Server - -from platypush.backend.http.app import template_folder -from platypush.backend.http.app.utils import authenticate -from platypush.backend.http.ws import events_redis_topic -from platypush.message.event import Event -from platypush.utils import get_redis - - -ws = Blueprint('ws', __name__, template_folder=template_folder) - -__routes__ = [ws] - -logger = getLogger(__name__) - - -@ws.route('/ws/events', websocket=True) -@authenticate(json=True) -def ws_events_route(): - """ - A websocket endpoint to asynchronously receive events generated from the - application. - - This endpoint is mainly used by web clients to listen for the events - generated by the application. - """ - - sock = Server(request.environ, ping_interval=25) - ws_key = (sock.environ['REMOTE_ADDR'], int(sock.environ['REMOTE_PORT'])) - sub = get_redis().pubsub() - sub.subscribe(events_redis_topic) - logger.info('Started websocket connection with %s', ws_key) - - try: - for msg in sub.listen(): - if ( - msg.get('type') != 'message' - and msg.get('channel').decode() != events_redis_topic - ): - continue - - try: - evt = Event.build(msg.get('data').decode()) - except Exception as e: - logger.warning('Error parsing event: %s: %s', msg.get('data'), e) - continue - - sock.send(str(evt)) - except ConnectionClosed as e: - logger.info( - 'Websocket connection to %s closed, reason=%s, message=%s', - ws_key, - e.reason, - e.message, - ) - finally: - sub.unsubscribe(events_redis_topic) - - return '' - - -# vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/ws.py b/platypush/backend/http/ws.py index bac62a87..2dcbee8a 100644 --- a/platypush/backend/http/ws.py +++ b/platypush/backend/http/ws.py @@ -1,3 +1,75 @@ -from platypush.config import Config +from logging import getLogger +from threading import Thread +from typing_extensions import override -events_redis_topic = f'__platypush/{Config.get("device_id")}/events' # type: ignore +from redis import ConnectionError +from tornado.ioloop import IOLoop +from tornado.websocket import WebSocketHandler + +from platypush.config import Config +from platypush.message.event import Event +from platypush.utils import get_redis + +events_redis_topic = f'_platypush/{Config.get("device_id")}/events' # type: ignore +logger = getLogger(__name__) + + +class WSEventProxy(WebSocketHandler, Thread): + """ + Websocket event proxy mapped to ``/ws/events``. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sub = get_redis().pubsub() + self._io_loop = IOLoop.current() + + @override + def open(self, *_, **__): + logger.info('Started websocket connection with %s', self.request.remote_ip) + self.name = f'ws:events@{self.request.remote_ip}' + self.start() + + @override + def on_message(self, *_, **__): + pass + + @override + def data_received(self, *_, **__): + pass + + @override + def run(self) -> None: + super().run() + self._sub.subscribe(events_redis_topic) + + try: + for msg in self._sub.listen(): + if ( + msg.get('type') != 'message' + and msg.get('channel').decode() != events_redis_topic + ): + continue + + try: + evt = Event.build(msg.get('data').decode()) + except Exception as e: + logger.warning('Error parsing event: %s: %s', msg.get('data'), e) + continue + + self._io_loop.asyncio_loop.call_soon_threadsafe( # type: ignore + self.write_message, str(evt) + ) + except ConnectionError: + pass + + @override + def on_close(self): + self._sub.unsubscribe(events_redis_topic) + self._sub.close() + logger.info( + 'Websocket connection to %s closed, reason=%s, message=%s', + self.request.remote_ip, + self.close_code, + self.close_reason, + ) diff --git a/platypush/backend/http/wsgi/__init__.py b/platypush/backend/http/wsgi/__init__.py deleted file mode 100644 index a2798e05..00000000 --- a/platypush/backend/http/wsgi/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Any, Dict - -from gunicorn.app.wsgiapp import WSGIApplication -from uvicorn.workers import UvicornWorker as BaseUvicornWorker - - -class UvicornWorker(BaseUvicornWorker): - CONFIG_KWARGS: Dict[str, Any] = {"loop": "auto", "http": "auto", "lifespan": "on"} - - -class WSGIApplicationWrapper(WSGIApplication): - """ - Wrapper for the Flask application into a WSGI application. - """ - - def __init__(self, app_uri, options=None): - self.options = options or {} - self.app_uri = app_uri - super().__init__() - - def load_config(self): - config = { - key: value - for key, value in self.options.items() - if key in self.cfg.settings and value is not None # type: ignore - } - for key, value in config.items(): - self.cfg.set(key.lower(), value) # type: ignore diff --git a/platypush/backend/nfc/__init__.py b/platypush/backend/nfc/__init__.py index fe1ca3d4..c481b77a 100644 --- a/platypush/backend/nfc/__init__.py +++ b/platypush/backend/nfc/__init__.py @@ -2,8 +2,12 @@ import base64 import json from platypush.backend import Backend -from platypush.message.event.nfc import NFCTagDetectedEvent, NFCTagRemovedEvent, NFCDeviceConnectedEvent, \ - NFCDeviceDisconnectedEvent +from platypush.message.event.nfc import ( + NFCTagDetectedEvent, + NFCTagRemovedEvent, + NFCDeviceConnectedEvent, + NFCDeviceDisconnectedEvent, +) class NfcBackend(Backend): @@ -20,7 +24,7 @@ class NfcBackend(Backend): Requires: * **nfcpy** >= 1.0 (``pip install 'nfcpy>=1.0'``) - * **ndef** (``pip install ndef``) + * **ndef** (``pip install ndeflib``) Run the following to check if your device is compatible with nfcpy and the right permissions are set:: @@ -49,7 +53,11 @@ class NfcBackend(Backend): 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())) + self.logger.info( + 'Initialized NFC reader backend on device {}'.format( + self._get_device_str() + ) + ) return self._clf @@ -107,51 +115,92 @@ class NfcBackend(Backend): r = { **r, 'type': 'smartposter', - **{attr: getattr(record, attr) for attr in ['resource', 'titles', 'title', 'action', 'icon', - 'icons', 'resource_size', 'resource_type']}, + **{ + attr: getattr(record, attr) + for attr in [ + 'resource', + 'titles', + 'title', + 'action', + 'icon', + 'icons', + 'resource_size', + 'resource_type', + ] + }, } elif isinstance(record, DeviceInformationRecord): r = { **r, 'type': 'device_info', - **{attr: getattr(record, attr) for attr in ['vendor_name', 'model_name', 'unique_name', - 'uuid_string', 'version_string']}, + **{ + attr: getattr(record, attr) + for attr in [ + 'vendor_name', + 'model_name', + 'unique_name', + 'uuid_string', + 'version_string', + ] + }, } elif isinstance(record, WifiSimpleConfigRecord): r = { **r, 'type': 'wifi_simple_config', - **{attr: record[attr] for attr in record.attribute_names()} + **{attr: record[attr] for attr in record.attribute_names()}, } elif isinstance(record, WifiPeerToPeerRecord): r = { **r, 'type': 'wifi_peer_to_peer', - **{attr: record[attr] for attr in record.attribute_names()} + **{attr: record[attr] for attr in record.attribute_names()}, } elif isinstance(record, BluetoothEasyPairingRecord): r = { **r, 'type': 'bluetooth_easy_pairing', - **{attr: getattr(record, attr) for attr in ['device_address', 'device_name', 'device_class']}, + **{ + attr: getattr(record, attr) + for attr in ['device_address', 'device_name', 'device_class'] + }, } elif isinstance(record, 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']}, + **{ + 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, 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']}, + **{ + 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 = { @@ -175,7 +224,11 @@ class NfcBackend(Backend): 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)) + self.bus.post( + NFCTagDetectedEvent( + reader=self._get_device_str(), tag_id=tag_id, records=records + ) + ) return True return callback @@ -183,7 +236,9 @@ class NfcBackend(Backend): 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)) + self.bus.post( + NFCTagRemovedEvent(reader=self._get_device_str(), tag_id=tag_id) + ) return callback @@ -193,10 +248,12 @@ class NfcBackend(Backend): while not self.should_stop(): try: clf = self._get_clf() - clf.connect(rdwr={ - 'on-connect': self._on_connect(), - 'on-release': self._on_release(), - }) + clf.connect( + rdwr={ + 'on-connect': self._on_connect(), + 'on-release': self._on_release(), + } + ) finally: self.close() diff --git a/platypush/backend/nfc/manifest.yaml b/platypush/backend/nfc/manifest.yaml index 90f8ef5b..f8259dfc 100644 --- a/platypush/backend/nfc/manifest.yaml +++ b/platypush/backend/nfc/manifest.yaml @@ -8,6 +8,7 @@ manifest: platypush.message.event.nfc.NFCTagRemovedEvent: when an NFC tag is removed install: pip: - - ndef + - nfcpy>=1.0 + - ndeflib package: platypush.backend.nfc type: backend diff --git a/platypush/backend/weather/buienradar/manifest.yaml b/platypush/backend/weather/buienradar/manifest.yaml index 12df0118..130a4201 100644 --- a/platypush/backend/weather/buienradar/manifest.yaml +++ b/platypush/backend/weather/buienradar/manifest.yaml @@ -3,6 +3,7 @@ manifest: platypush.message.event.weather.NewWeatherConditionEvent: when there is a weather condition update install: - pip: [] + pip: + - buienradar package: platypush.backend.weather.buienradar type: backend diff --git a/platypush/plugins/assistant/google/pushtotalk/__init__.py b/platypush/plugins/assistant/google/pushtotalk/__init__.py index 13afb59c..d1a511ca 100644 --- a/platypush/plugins/assistant/google/pushtotalk/__init__.py +++ b/platypush/plugins/assistant/google/pushtotalk/__init__.py @@ -7,9 +7,13 @@ import os from typing import Optional, Dict, Any from platypush.context import get_bus, get_plugin -from platypush.message.event.assistant import ConversationStartEvent, \ - ConversationEndEvent, SpeechRecognizedEvent, VolumeChangedEvent, \ - ResponseEvent +from platypush.message.event.assistant import ( + ConversationStartEvent, + ConversationEndEvent, + SpeechRecognizedEvent, + VolumeChangedEvent, + ResponseEvent, +) from platypush.message.event.google import GoogleDeviceOnOffEvent @@ -34,29 +38,42 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): * **tenacity** (``pip install tenacity``) * **google-assistant-sdk** (``pip install google-assistant-sdk[samples]``) + * **google-auth** (``pip install google-auth``) """ api_endpoint = 'embeddedassistant.googleapis.com' grpc_deadline = 60 * 3 + 5 device_handler = None + _default_credentials_file = os.path.join( + os.path.expanduser('~'), + '.config', + 'google-oauthlib-tool', + 'credentials.json', + ) - def __init__(self, - credentials_file=os.path.join( - os.path.expanduser('~'), '.config', - 'google-oauthlib-tool', 'credentials.json'), - device_config=os.path.join( - os.path.expanduser('~'), '.config', 'googlesamples-assistant', - 'device_config.json'), - language='en-US', - play_response=True, - tts_plugin=None, - tts_args=None, - **kwargs): + _default_device_config = os.path.join( + os.path.expanduser('~'), + '.config', + 'googlesamples-assistant', + 'device_config.json', + ) + + def __init__( + self, + credentials_file=_default_credentials_file, + device_config=_default_device_config, + language='en-US', + play_response=True, + tts_plugin=None, + tts_args=None, + **kwargs + ): """ :param credentials_file: Path to the Google OAuth credentials file (default: ~/.config/google-oauthlib-tool/credentials.json). - See https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials + See + https://developers.google.com/assistant/sdk/guides/library/python/embed/install-sample#generate_credentials for instructions to get your own credentials file. :type credentials_file: str @@ -81,6 +98,7 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): """ import googlesamples.assistant.grpc.audio_helpers as audio_helpers + super().__init__(**kwargs) self.audio_sample_rate = audio_helpers.DEFAULT_AUDIO_SAMPLE_RATE @@ -114,8 +132,9 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): self.credentials.refresh(self.http_request) except Exception as ex: self.logger.error('Error loading credentials: %s', str(ex)) - self.logger.error('Run google-oauthlib-tool to initialize ' - 'new OAuth 2.0 credentials.') + self.logger.error( + 'Run google-oauthlib-tool to initialize ' 'new OAuth 2.0 credentials.' + ) raise self.grpc_channel = None @@ -128,27 +147,25 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): self.interactions = [] # Create an authorized gRPC channel. - self.grpc_channel = secure_authorized_channel(self.credentials, self.http_request, self.api_endpoint) + self.grpc_channel = secure_authorized_channel( + self.credentials, self.http_request, self.api_endpoint + ) self.logger.info('Connecting to {}'.format(self.api_endpoint)) # Configure audio source and sink. audio_device = None - audio_source = audio_device = ( - audio_device or audio_helpers.SoundDeviceStream( - sample_rate=self.audio_sample_rate, - sample_width=self.audio_sample_width, - block_size=self.audio_block_size, - flush_size=self.audio_flush_size - ) + audio_source = audio_device = audio_device or audio_helpers.SoundDeviceStream( + sample_rate=self.audio_sample_rate, + sample_width=self.audio_sample_width, + block_size=self.audio_block_size, + flush_size=self.audio_flush_size, ) - audio_sink = ( - audio_device or audio_helpers.SoundDeviceStream( - sample_rate=self.audio_sample_rate, - sample_width=self.audio_sample_width, - block_size=self.audio_block_size, - flush_size=self.audio_flush_size - ) + audio_sink = audio_device or audio_helpers.SoundDeviceStream( + sample_rate=self.audio_sample_rate, + sample_width=self.audio_sample_width, + block_size=self.audio_block_size, + flush_size=self.audio_flush_size, ) # Create conversation stream with the given audio source and sink. @@ -162,21 +179,28 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): self._install_device_handlers() def on_conversation_start(self): - """ Conversation start handler """ + """Conversation start handler""" + def handler(): get_bus().post(ConversationStartEvent(assistant=self)) return handler def on_conversation_end(self): - """ Conversation end handler """ + """Conversation end handler""" + def handler(with_follow_on_turn): - get_bus().post(ConversationEndEvent(assistant=self, with_follow_on_turn=with_follow_on_turn)) + get_bus().post( + ConversationEndEvent( + assistant=self, with_follow_on_turn=with_follow_on_turn + ) + ) return handler def on_speech_recognized(self): - """ Speech recognized handler """ + """Speech recognized handler""" + def handler(phrase): get_bus().post(SpeechRecognizedEvent(assistant=self, phrase=phrase)) self.interactions.append({'request': phrase}) @@ -184,14 +208,16 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): return handler def on_volume_changed(self): - """ Volume changed event """ + """Volume changed event""" + def handler(volume): get_bus().post(VolumeChangedEvent(assistant=self, volume=volume)) return handler def on_response(self): - """ Response handler """ + """Response handler""" + def handler(response): get_bus().post(ResponseEvent(assistant=self, response_text=response)) @@ -207,8 +233,14 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): return handler @action - def start_conversation(self, *args, language: Optional[str] = None, tts_plugin: Optional[str] = None, - tts_args: Optional[Dict[str, Any]] = None, **kwargs): + def start_conversation( + self, + *_, + language: Optional[str] = None, + tts_plugin: Optional[str] = None, + tts_args: Optional[Dict[str, Any]] = None, + **__ + ): """ Start a conversation @@ -242,27 +274,31 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): self._init_assistant() self.on_conversation_start() - with SampleAssistant(language_code=language, - device_model_id=self.device_model_id, - device_id=self.device_id, - conversation_stream=self.conversation_stream, - display=None, - channel=self.grpc_channel, - deadline_sec=self.grpc_deadline, - play_response=play_response, - device_handler=self.device_handler, - on_conversation_start=self.on_conversation_start(), - on_conversation_end=self.on_conversation_end(), - on_volume_changed=self.on_volume_changed(), - on_response=self.on_response(), - on_speech_recognized=self.on_speech_recognized()) as self.assistant: + with SampleAssistant( + language_code=language, + device_model_id=self.device_model_id, + device_id=self.device_id, + conversation_stream=self.conversation_stream, + display=None, + channel=self.grpc_channel, + deadline_sec=self.grpc_deadline, + play_response=play_response, + device_handler=self.device_handler, + on_conversation_start=self.on_conversation_start(), + on_conversation_end=self.on_conversation_end(), + on_volume_changed=self.on_volume_changed(), + on_response=self.on_response(), + on_speech_recognized=self.on_speech_recognized(), + ) as self.assistant: continue_conversation = True while continue_conversation: try: continue_conversation = self.assistant.assist() except Exception as e: - self.logger.warning('Unhandled assistant exception: {}'.format(str(e))) + self.logger.warning( + 'Unhandled assistant exception: {}'.format(str(e)) + ) self.logger.exception(e) self._init_assistant() @@ -297,14 +333,18 @@ class AssistantGooglePushtotalkPlugin(AssistantPlugin): def _install_device_handlers(self): import googlesamples.assistant.grpc.device_helpers as device_helpers + self.device_handler = device_helpers.DeviceRequestHandler(self.device_id) @self.device_handler.command('action.devices.commands.OnOff') - def handler(on): - get_bus().post(GoogleDeviceOnOffEvent( - device_id=self.device_id, - device_model_id=self.device_model_id, - on=on)) + def handler(on): # type: ignore + get_bus().post( + GoogleDeviceOnOffEvent( + device_id=self.device_id, + device_model_id=self.device_model_id, + on=on, + ) + ) # vim:sw=4:ts=4:et: diff --git a/platypush/plugins/assistant/google/pushtotalk/manifest.yaml b/platypush/plugins/assistant/google/pushtotalk/manifest.yaml index 2f374507..efcd1a89 100644 --- a/platypush/plugins/assistant/google/pushtotalk/manifest.yaml +++ b/platypush/plugins/assistant/google/pushtotalk/manifest.yaml @@ -10,5 +10,6 @@ manifest: pip: - tenacity - google-assistant-sdk + - google-auth package: platypush.plugins.assistant.google.pushtotalk type: plugin diff --git a/platypush/plugins/camera/gstreamer/__init__.py b/platypush/plugins/camera/gstreamer/__init__.py index 14a1d5fe..bb745b3c 100644 --- a/platypush/plugins/camera/gstreamer/__init__.py +++ b/platypush/plugins/camera/gstreamer/__init__.py @@ -15,6 +15,7 @@ class CameraGstreamerPlugin(CameraPlugin): Requires: * **gst-python** + * **pygobject** On Debian and derived systems: @@ -39,15 +40,24 @@ class CameraGstreamerPlugin(CameraPlugin): pipeline = Pipeline() src = pipeline.add_source('v4l2src', device=camera.info.device) convert = pipeline.add('videoconvert') + assert camera.info and camera.info.resolution + video_filter = pipeline.add( - 'capsfilter', caps='video/x-raw,format=RGB,width={width},height={height},framerate={fps}/1'.format( - width=camera.info.resolution[0], height=camera.info.resolution[1], fps=camera.info.fps)) + 'capsfilter', + caps='video/x-raw,format=RGB,width={width},height={height},framerate={fps}/1'.format( + width=camera.info.resolution[0], + height=camera.info.resolution[1], + fps=camera.info.fps, + ), + ) sink = pipeline.add_sink('appsink', name='appsink', sync=False) pipeline.link(src, convert, video_filter, sink) return pipeline - def start_camera(self, camera: GStreamerCamera, preview: bool = False, *args, **kwargs): + def start_camera( + self, camera: GStreamerCamera, preview: bool = False, *args, **kwargs + ): super().start_camera(*args, camera=camera, preview=preview, **kwargs) if camera.object: camera.object.play() @@ -56,16 +66,27 @@ class CameraGstreamerPlugin(CameraPlugin): if camera.object: camera.object.stop() - def capture_frame(self, camera: GStreamerCamera, *args, **kwargs) -> Optional[ImageType]: - timed_out = not camera.object.data_ready.wait(timeout=5 + (1. / camera.info.fps)) + def capture_frame(self, camera: GStreamerCamera, *_, **__) -> Optional[ImageType]: + if not (camera.info and camera.info.fps and camera.info.resolution): + return None + + timed_out = not camera.object.data_ready.wait( + timeout=5 + (1.0 / camera.info.fps) + ) if timed_out: self.logger.warning('Frame capture timeout') - return + return None data = camera.object.data + if data is None: + return None + camera.object.data_ready.clear() - if not data and len(data) != camera.info.resolution[0] * camera.info.resolution[1] * 3: - return + if ( + not data + and len(data) != camera.info.resolution[0] * camera.info.resolution[1] * 3 + ): + return None return Image.frombytes('RGB', camera.info.resolution, data) diff --git a/platypush/plugins/camera/gstreamer/manifest.yaml b/platypush/plugins/camera/gstreamer/manifest.yaml index 9eec1699..43e2cae4 100644 --- a/platypush/plugins/camera/gstreamer/manifest.yaml +++ b/platypush/plugins/camera/gstreamer/manifest.yaml @@ -4,6 +4,7 @@ manifest: pip: - numpy - Pillow + - pygobject apt: - python3-gi - python3-gst-1.0 diff --git a/platypush/plugins/google/calendar/manifest.yaml b/platypush/plugins/google/calendar/manifest.yaml index f73f0f58..c1b373d4 100644 --- a/platypush/plugins/google/calendar/manifest.yaml +++ b/platypush/plugins/google/calendar/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client package: platypush.plugins.google.calendar type: plugin diff --git a/platypush/plugins/google/drive/manifest.yaml b/platypush/plugins/google/drive/manifest.yaml index 13fc150f..6114d3a2 100644 --- a/platypush/plugins/google/drive/manifest.yaml +++ b/platypush/plugins/google/drive/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client package: platypush.plugins.google.drive type: plugin diff --git a/platypush/plugins/google/fit/manifest.yaml b/platypush/plugins/google/fit/manifest.yaml index abd723ff..390b8172 100644 --- a/platypush/plugins/google/fit/manifest.yaml +++ b/platypush/plugins/google/fit/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client package: platypush.plugins.google.fit type: plugin diff --git a/platypush/plugins/google/mail/manifest.yaml b/platypush/plugins/google/mail/manifest.yaml index fe047290..1a7df968 100644 --- a/platypush/plugins/google/mail/manifest.yaml +++ b/platypush/plugins/google/mail/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client package: platypush.plugins.google.mail type: plugin diff --git a/platypush/plugins/google/maps/manifest.yaml b/platypush/plugins/google/maps/manifest.yaml index 227a36ec..2af250c2 100644 --- a/platypush/plugins/google/maps/manifest.yaml +++ b/platypush/plugins/google/maps/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client package: platypush.plugins.google.maps type: plugin diff --git a/platypush/plugins/google/pubsub/manifest.yaml b/platypush/plugins/google/pubsub/manifest.yaml index 569b3c9c..f546ba3c 100644 --- a/platypush/plugins/google/pubsub/manifest.yaml +++ b/platypush/plugins/google/pubsub/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client - google-cloud-pubsub package: platypush.plugins.google.pubsub diff --git a/platypush/plugins/google/translate/manifest.yaml b/platypush/plugins/google/translate/manifest.yaml index 561878ca..bee4f43d 100644 --- a/platypush/plugins/google/translate/manifest.yaml +++ b/platypush/plugins/google/translate/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client - google-cloud-translate package: platypush.plugins.google.translate diff --git a/platypush/plugins/google/youtube/manifest.yaml b/platypush/plugins/google/youtube/manifest.yaml index a08ca8f5..94ee6d67 100644 --- a/platypush/plugins/google/youtube/manifest.yaml +++ b/platypush/plugins/google/youtube/manifest.yaml @@ -3,6 +3,7 @@ manifest: install: pip: - google-api-python-client + - google-auth - oauth2client package: platypush.plugins.google.youtube type: plugin diff --git a/platypush/plugins/media/__init__.py b/platypush/plugins/media/__init__.py index a2abe096..67949857 100644 --- a/platypush/plugins/media/__init__.py +++ b/platypush/plugins/media/__init__.py @@ -30,7 +30,7 @@ class MediaPlugin(Plugin, ABC): Requires: * A media player installed (supported so far: mplayer, vlc, mpv, omxplayer, chromecast) - * **python-libtorrent-bin** (``pip install python-libtorrent-bin``), optional, for torrent support over native + * **python-libtorrent** (``pip install python-libtorrent``), optional, for torrent support over native library * *rtorrent* installed - optional, for torrent support over rtorrent * **youtube-dl** installed on your system (see your distro instructions), optional for YouTube support @@ -43,41 +43,123 @@ class MediaPlugin(Plugin, ABC): # A media plugin can either be local or remote (e.g. control media on # another device) _is_local = True - _NOT_IMPLEMENTED_ERR = NotImplementedError('This method must be implemented in a derived class') + _NOT_IMPLEMENTED_ERR = NotImplementedError( + 'This method must be implemented in a derived class' + ) # Supported audio extensions audio_extensions = { - '3gp', 'aa', 'aac', 'aax', 'act', 'aiff', 'amr', 'ape', 'au', - 'awb', 'dct', 'dss', 'dvf', 'flac', 'gsm', 'iklax', 'ivs', - 'm4a', 'm4b', 'm4p', 'mmf', 'mp3', 'mpc', 'msv', 'nmf', 'nsf', - 'ogg,', 'opus', 'ra,', 'raw', 'sln', 'tta', 'vox', 'wav', - 'wma', 'wv', 'webm', '8svx', + '3gp', + 'aa', + 'aac', + 'aax', + 'act', + 'aiff', + 'amr', + 'ape', + 'au', + 'awb', + 'dct', + 'dss', + 'dvf', + 'flac', + 'gsm', + 'iklax', + 'ivs', + 'm4a', + 'm4b', + 'm4p', + 'mmf', + 'mp3', + 'mpc', + 'msv', + 'nmf', + 'nsf', + 'ogg,', + 'opus', + 'ra,', + 'raw', + 'sln', + 'tta', + 'vox', + 'wav', + 'wma', + 'wv', + 'webm', + '8svx', } # Supported video extensions video_extensions = { - 'webm', 'mkv', 'flv', 'flv', 'vob', 'ogv', 'ogg', 'drc', 'gif', - 'gifv', 'mng', 'avi', 'mts', 'm2ts', 'mov', 'qt', 'wmv', 'yuv', - 'rm', 'rmvb', 'asf', 'amv', 'mp4', 'm4p', 'm4v', 'mpg', 'mp2', - 'mpeg', 'mpe', 'mpv', 'mpg', 'mpeg', 'm2v', 'm4v', 'svi', - '3gp', '3g2', 'mxf', 'roq', 'nsv', 'flv', 'f4v', 'f4p', 'f4a', + 'webm', + 'mkv', + 'flv', + 'flv', + 'vob', + 'ogv', + 'ogg', + 'drc', + 'gif', + 'gifv', + 'mng', + 'avi', + 'mts', + 'm2ts', + 'mov', + 'qt', + 'wmv', + 'yuv', + 'rm', + 'rmvb', + 'asf', + 'amv', + 'mp4', + 'm4p', + 'm4v', + 'mpg', + 'mp2', + 'mpeg', + 'mpe', + 'mpv', + 'mpg', + 'mpeg', + 'm2v', + 'm4v', + 'svi', + '3gp', + '3g2', + 'mxf', + 'roq', + 'nsv', + 'flv', + 'f4v', + 'f4p', + 'f4a', 'f4b', } - _supported_media_plugins = {'media.mplayer', 'media.omxplayer', 'media.mpv', - 'media.vlc', 'media.chromecast', 'media.gstreamer'} + _supported_media_plugins = { + 'media.mplayer', + 'media.omxplayer', + 'media.mpv', + 'media.vlc', + 'media.chromecast', + 'media.gstreamer', + } _supported_media_types = ['file', 'jellyfin', 'plex', 'torrent', 'youtube'] _default_search_timeout = 60 # 60 seconds - def __init__(self, - media_dirs: Optional[List[str]] = None, - download_dir: Optional[str] = None, - env: Optional[Dict[str, str]] = None, - volume: Optional[Union[float, int]] = None, - torrent_plugin: str = 'torrent', - youtube_format: str = 'best', - *args, **kwargs): + def __init__( + self, + media_dirs: Optional[List[str]] = None, + download_dir: Optional[str] = None, + env: Optional[Dict[str, str]] = None, + volume: Optional[Union[float, int]] = None, + torrent_plugin: str = 'torrent', + youtube_format: str = 'best', + **kwargs, + ): """ :param media_dirs: Directories that will be scanned for media files when a search is performed (default: none) @@ -134,17 +216,21 @@ class MediaPlugin(Plugin, ABC): self._env = env or {} self.media_dirs = set( filter( - lambda _: os.path.isdir(_), - map( - lambda _: os.path.abspath(os.path.expanduser(_)), - media_dirs - ) + os.path.isdir, + [os.path.abspath(os.path.expanduser(d)) for d in media_dirs], ) ) - self.download_dir = os.path.abspath(os.path.expanduser( - download_dir or player_config.get('download_dir') or - os.path.join((os.path.expanduser('~') or self._env.get('HOME') or '/'), 'Downloads'))) + self.download_dir = os.path.abspath( + os.path.expanduser( + download_dir + or player_config.get('download_dir') + or os.path.join( + (os.path.expanduser('~') or self._env.get('HOME') or '/'), + 'Downloads', + ) + ) + ) if not os.path.isdir(self.download_dir): os.makedirs(self.download_dir, exist_ok=True) @@ -162,14 +248,17 @@ class MediaPlugin(Plugin, ABC): # More than 5% of the torrent has been downloaded if event.args.get('progress', 0) > 5 and event.args.get('files'): evt_queue.put(event.args['files']) + return handler @staticmethod def _is_youtube_resource(resource): - return resource.startswith('youtube:') \ - or resource.startswith('https://youtu.be/') \ - or resource.startswith('https://www.youtube.com/watch?v=') \ - or resource.startswith('https://youtube.com/watch?v=') + return ( + resource.startswith('youtube:') + or resource.startswith('https://youtu.be/') + or resource.startswith('https://www.youtube.com/watch?v=') + or resource.startswith('https://youtube.com/watch?v=') + ) def _get_resource(self, resource): """ @@ -194,15 +283,21 @@ class MediaPlugin(Plugin, ABC): resource = self.get_youtube_video_url(resource) elif resource.startswith('magnet:?'): - self.logger.info('Downloading torrent {} to {}'.format( - resource, self.download_dir)) + self.logger.info( + 'Downloading torrent {} to {}'.format(resource, self.download_dir) + ) torrents = get_plugin(self.torrent_plugin) evt_queue = queue.Queue() - torrents.download(resource, download_dir=self.download_dir, _async=True, is_media=True, - event_hndl=self._torrent_event_handler(evt_queue)) + torrents.download( + resource, + download_dir=self.download_dir, + _async=True, + is_media=True, + event_hndl=self._torrent_event_handler(evt_queue), + ) - resources = [f for f in evt_queue.get()] + resources = [f for f in evt_queue.get()] # noqa: C416 if resources: self._videos_queue = sorted(resources) @@ -265,7 +360,7 @@ class MediaPlugin(Plugin, ABC): @action def next(self): - """ Play the next item in the queue """ + """Play the next item in the queue""" self.stop() if self._videos_queue: @@ -323,8 +418,14 @@ class MediaPlugin(Plugin, ABC): raise self._NOT_IMPLEMENTED_ERR @action - def search(self, query, types=None, queue_results=False, autoplay=False, - search_timeout=_default_search_timeout): + def search( + self, + query, + types=None, + queue_results=False, + autoplay=False, + search_timeout=_default_search_timeout, + ): """ Perform a video search. @@ -356,8 +457,12 @@ class MediaPlugin(Plugin, ABC): results_queues[media_type] = queue.Queue() search_hndl = self._get_search_handler_by_type(media_type) worker_threads[media_type] = threading.Thread( - target=self._search_worker(query=query, search_hndl=search_hndl, - results_queue=results_queues[media_type])) + target=self._search_worker( + query=query, + search_hndl=search_hndl, + results_queue=results_queues[media_type], + ) + ) worker_threads[media_type].start() for media_type in types: @@ -368,11 +473,15 @@ class MediaPlugin(Plugin, ABC): results[media_type].extend(items) except queue.Empty: - self.logger.warning('Search for "{}" media type {} timed out'. - format(query, media_type)) + self.logger.warning( + 'Search for "{}" media type {} timed out'.format(query, media_type) + ) except Exception as e: - self.logger.warning('Error while searching for "{}", media type {}'. - format(query, media_type)) + self.logger.warning( + 'Error while searching for "{}", media type {}'.format( + query, media_type + ) + ) self.logger.exception(e) flattened_results = [] @@ -402,23 +511,29 @@ class MediaPlugin(Plugin, ABC): results_queue.put(search_hndl.search(query)) except Exception as e: results_queue.put(e) + return thread def _get_search_handler_by_type(self, search_type): if search_type == 'file': from .search import LocalMediaSearcher + return LocalMediaSearcher(self.media_dirs, media_plugin=self) if search_type == 'torrent': from .search import TorrentMediaSearcher + return TorrentMediaSearcher(media_plugin=self) if search_type == 'youtube': from .search import YoutubeMediaSearcher + return YoutubeMediaSearcher(media_plugin=self) if search_type == 'plex': from .search import PlexMediaSearcher + return PlexMediaSearcher(media_plugin=self) if search_type == 'jellyfin': from .search import JellyfinMediaSearcher + return JellyfinMediaSearcher(media_plugin=self) self.logger.warning('Unsupported search type: {}'.format(search_type)) @@ -463,18 +578,23 @@ class MediaPlugin(Plugin, ABC): http = get_backend('http') if not http: - self.logger.warning('Unable to stream {}: HTTP backend unavailable'. - format(media)) + self.logger.warning( + 'Unable to stream {}: HTTP backend unavailable'.format(media) + ) return self.logger.info('Starting streaming {}'.format(media)) - response = requests.put('{url}/media{download}'.format( - url=http.local_base_url, download='?download' if download else ''), - json={'source': media, 'subtitles': subtitles}) + response = requests.put( + '{url}/media{download}'.format( + url=http.local_base_url, download='?download' if download else '' + ), + json={'source': media, 'subtitles': subtitles}, + ) if not response.ok: - self.logger.warning('Unable to start streaming: {}'. - format(response.text or response.reason)) + self.logger.warning( + 'Unable to start streaming: {}'.format(response.text or response.reason) + ) return None, (response.text or response.reason) return response.json() @@ -483,16 +603,19 @@ class MediaPlugin(Plugin, ABC): def stop_streaming(self, media_id): http = get_backend('http') if not http: - self.logger.warning('Cannot unregister {}: HTTP backend unavailable'. - format(media_id)) + self.logger.warning( + 'Cannot unregister {}: HTTP backend unavailable'.format(media_id) + ) return - response = requests.delete('{url}/media/{id}'. - format(url=http.local_base_url, id=media_id)) + response = requests.delete( + '{url}/media/{id}'.format(url=http.local_base_url, id=media_id) + ) if not response.ok: - self.logger.warning('Unable to unregister media_id {}: {}'.format( - media_id, response.reason)) + self.logger.warning( + 'Unable to unregister media_id {}: {}'.format(media_id, response.reason) + ) return return response.json() @@ -511,11 +634,18 @@ class MediaPlugin(Plugin, ABC): @staticmethod def _youtube_search_html_parse(query): from .search import YoutubeMediaSearcher + # noinspection PyProtectedMember return YoutubeMediaSearcher()._youtube_search_html_parse(query) def get_youtube_video_url(self, url, youtube_format: Optional[str] = None): - ytdl_cmd = ['youtube-dl', '-f', youtube_format or self.youtube_format, '-g', url] + ytdl_cmd = [ + 'youtube-dl', + '-f', + youtube_format or self.youtube_format, + '-g', + url, + ] self.logger.info(f'Executing command {" ".join(ytdl_cmd)}') youtube_dl = subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) url = youtube_dl.communicate()[0].decode().strip() @@ -564,15 +694,29 @@ class MediaPlugin(Plugin, ABC): if filename.startswith('file://'): filename = filename[7:] - result = subprocess.Popen(["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + result = subprocess.Popen( + ["ffprobe", filename], stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) return functools.reduce( lambda t, t_i: t + t_i, - [float(t) * pow(60, i) for (i, t) in enumerate(re.search( - r'^Duration:\s*([^,]+)', [x.decode() - for x in result.stdout.readlines() - if "Duration" in x.decode()].pop().strip() - ).group(1).split(':')[::-1])] + [ + float(t) * pow(60, i) + for (i, t) in enumerate( + re.search( + r'^Duration:\s*([^,]+)', + [ + x.decode() + for x in result.stdout.readlines() + if "Duration" in x.decode() + ] + .pop() + .strip(), + ) + .group(1) + .split(':')[::-1] + ) + ], ) @action @@ -608,13 +752,14 @@ class MediaPlugin(Plugin, ABC): return if subtitles.startswith('file://'): - subtitles = subtitles[len('file://'):] + subtitles = subtitles[len('file://') :] if os.path.isfile(subtitles): return os.path.abspath(subtitles) else: content = requests.get(subtitles).content - f = tempfile.NamedTemporaryFile(prefix='media_subs_', - suffix='.srt', delete=False) + f = tempfile.NamedTemporaryFile( + prefix='media_subs_', suffix='.srt', delete=False + ) with f: f.write(content) diff --git a/platypush/plugins/media/gstreamer/__init__.py b/platypush/plugins/media/gstreamer/__init__.py index 557a65b7..44e6bbf5 100644 --- a/platypush/plugins/media/gstreamer/__init__.py +++ b/platypush/plugins/media/gstreamer/__init__.py @@ -15,6 +15,7 @@ class MediaGstreamerPlugin(MediaPlugin): Requires: * **gst-python** + * **pygobject** On Debian and derived systems: @@ -46,7 +47,7 @@ class MediaGstreamerPlugin(MediaPlugin): return pipeline @action - def play(self, resource: Optional[str] = None, **args): + def play(self, resource: Optional[str] = None, **_): """ Play a resource. @@ -67,20 +68,20 @@ class MediaGstreamerPlugin(MediaPlugin): pipeline = self._allocate_pipeline(resource) pipeline.play() if self.volume: - pipeline.set_volume(self.volume / 100.) + pipeline.set_volume(self.volume / 100.0) return self.status() @action def pause(self): - """ Toggle the paused state """ + """Toggle the paused state""" assert self._player, 'No instance is running' self._player.pause() return self.status() @action def quit(self): - """ Stop and quit the player (alias for :meth:`.stop`) """ + """Stop and quit the player (alias for :meth:`.stop`)""" self._stop_torrent() assert self._player, 'No instance is running' @@ -90,18 +91,18 @@ class MediaGstreamerPlugin(MediaPlugin): @action def stop(self): - """ Stop and quit the player (alias for :meth:`.quit`) """ + """Stop and quit the player (alias for :meth:`.quit`)""" return self.quit() @action def voldown(self, step=10.0): - """ Volume down by (default: 10)% """ + """Volume down by (default: 10)%""" # noinspection PyUnresolvedReferences return self.set_volume(self.get_volume().output - step) @action def volup(self, step=10.0): - """ Volume up by (default: 10)% """ + """Volume up by (default: 10)%""" # noinspection PyUnresolvedReferences return self.set_volume(self.get_volume().output + step) @@ -113,7 +114,7 @@ class MediaGstreamerPlugin(MediaPlugin): :return: Volume value between 0 and 100. """ assert self._player, 'No instance is running' - return self._player.get_volume() * 100. + return self._player.get_volume() * 100.0 @action def set_volume(self, volume): @@ -124,7 +125,7 @@ class MediaGstreamerPlugin(MediaPlugin): """ assert self._player, 'Player not running' # noinspection PyTypeChecker - volume = max(0, min(1, volume / 100.)) + volume = max(0, min(1, volume / 100.0)) self._player.set_volume(volume) MediaPipeline.post_event(MediaVolumeChangedEvent, volume=volume * 100) return self.status() @@ -142,12 +143,12 @@ class MediaGstreamerPlugin(MediaPlugin): @action def back(self, offset=60.0): - """ Back by (default: 60) seconds """ + """Back by (default: 60) seconds""" return self.seek(-offset) @action def forward(self, offset=60.0): - """ Forward by (default: 60) seconds """ + """Forward by (default: 60) seconds""" return self.seek(offset) @action @@ -158,7 +159,7 @@ class MediaGstreamerPlugin(MediaPlugin): return self._player and self._player.is_playing() @action - def load(self, resource, **args): + def load(self, resource, **_): """ Load/queue a resource/video to the player (alias for :meth:`.play`). """ @@ -166,7 +167,7 @@ class MediaGstreamerPlugin(MediaPlugin): @action def mute(self): - """ Toggle mute state """ + """Toggle mute state""" assert self._player, 'No instance is running' muted = self._player.is_muted() if muted: @@ -201,11 +202,15 @@ class MediaGstreamerPlugin(MediaPlugin): return { 'duration': length, - 'filename': self._resource[7:] if self._resource.startswith('file://') else self._resource, + 'filename': self._resource[7:] + if self._resource.startswith('file://') + else self._resource, 'mute': self._player.is_muted(), 'name': self._resource, 'pause': self._player.is_paused(), - 'percent_pos': pos / length if pos is not None and length is not None and pos >= 0 and length > 0 else 0, + 'percent_pos': pos / length + if pos is not None and length is not None and pos >= 0 and length > 0 + else 0, 'position': pos, 'seekable': length is not None and length > 0, 'state': self._gst_to_player_state(self._player.get_state()).value, @@ -217,6 +222,7 @@ class MediaGstreamerPlugin(MediaPlugin): def _gst_to_player_state(state) -> PlayerState: # noinspection PyUnresolvedReferences,PyPackageRequirements from gi.repository import Gst + if state == Gst.State.READY: return PlayerState.STOP if state == Gst.State.PAUSED: @@ -225,13 +231,13 @@ class MediaGstreamerPlugin(MediaPlugin): return PlayerState.PLAY return PlayerState.IDLE - def toggle_subtitles(self, *args, **kwargs): + def toggle_subtitles(self, *_, **__): raise NotImplementedError - def set_subtitles(self, filename, *args, **kwargs): + def set_subtitles(self, *_, **__): raise NotImplementedError - def remove_subtitles(self, *args, **kwargs): + def remove_subtitles(self, *_, **__): raise NotImplementedError diff --git a/platypush/plugins/media/gstreamer/manifest.yaml b/platypush/plugins/media/gstreamer/manifest.yaml index bdc0c567..8e50d745 100644 --- a/platypush/plugins/media/gstreamer/manifest.yaml +++ b/platypush/plugins/media/gstreamer/manifest.yaml @@ -4,6 +4,8 @@ manifest: apt: - python3-gi - python3-gst-1.0 + pip: + - pygobject pacman: - gst-python - python-gobject diff --git a/platypush/plugins/printer/cups/manifest.yaml b/platypush/plugins/printer/cups/manifest.yaml index 1d832d6c..fa8e2c61 100644 --- a/platypush/plugins/printer/cups/manifest.yaml +++ b/platypush/plugins/printer/cups/manifest.yaml @@ -3,5 +3,7 @@ manifest: install: pip: - pycups + apt: + - libcups2-dev package: platypush.plugins.printer.cups type: plugin diff --git a/platypush/plugins/sensor/ltr559/__init__.py b/platypush/plugins/sensor/ltr559/__init__.py index 840ffed6..35befb6f 100644 --- a/platypush/plugins/sensor/ltr559/__init__.py +++ b/platypush/plugins/sensor/ltr559/__init__.py @@ -19,6 +19,7 @@ class SensorLtr559Plugin(SensorPlugin): Requires: * ``ltr559`` (``pip install ltr559``) + * ``smbus`` (``pip install smbus``) Triggers: diff --git a/platypush/plugins/sensor/ltr559/manifest.yaml b/platypush/plugins/sensor/ltr559/manifest.yaml index 6ec27a59..b2efad9c 100644 --- a/platypush/plugins/sensor/ltr559/manifest.yaml +++ b/platypush/plugins/sensor/ltr559/manifest.yaml @@ -6,5 +6,6 @@ manifest: install: pip: - ltr559 + - smbus package: platypush.plugins.sensor.ltr559 type: plugin diff --git a/platypush/plugins/torrent/__init__.py b/platypush/plugins/torrent/__init__.py index 682f9f3d..71370795 100644 --- a/platypush/plugins/torrent/__init__.py +++ b/platypush/plugins/torrent/__init__.py @@ -9,10 +9,17 @@ import time from platypush.context import get_bus from platypush.plugins import Plugin, action -from platypush.message.event.torrent import \ - TorrentDownloadStartEvent, TorrentDownloadedMetadataEvent, TorrentStateChangeEvent, \ - TorrentDownloadProgressEvent, TorrentDownloadCompletedEvent, TorrentDownloadStopEvent, \ - TorrentPausedEvent, TorrentResumedEvent, TorrentQueuedEvent +from platypush.message.event.torrent import ( + TorrentDownloadStartEvent, + TorrentDownloadedMetadataEvent, + TorrentStateChangeEvent, + TorrentDownloadProgressEvent, + TorrentDownloadCompletedEvent, + TorrentDownloadStopEvent, + TorrentPausedEvent, + TorrentResumedEvent, + TorrentQueuedEvent, +) class TorrentPlugin(Plugin): @@ -21,7 +28,7 @@ class TorrentPlugin(Plugin): Requires: - * **python-libtorrent-bin** (``pip install python-libtorrent-bin``) + * **python-libtorrent** (``pip install python-libtorrent``) """ @@ -39,8 +46,14 @@ class TorrentPlugin(Plugin): # noinspection HttpUrlsUsage default_popcorn_base_url = 'http://popcorn-time.ga' - def __init__(self, download_dir=None, torrent_ports=None, imdb_key=None, popcorn_base_url=default_popcorn_base_url, - **kwargs): + def __init__( + self, + download_dir=None, + torrent_ports=None, + imdb_key=None, + popcorn_base_url=default_popcorn_base_url, + **kwargs, + ): """ :param download_dir: Directory where the videos/torrents will be downloaded (default: none) :type download_dir: str @@ -66,7 +79,9 @@ class TorrentPlugin(Plugin): self.imdb_key = imdb_key self.imdb_urls = {} - self.torrent_ports = torrent_ports if torrent_ports else self.default_torrent_ports + self.torrent_ports = ( + torrent_ports if torrent_ports else self.default_torrent_ports + ) self.download_dir = None self._sessions = {} self._lt_session = None @@ -109,11 +124,16 @@ class TorrentPlugin(Plugin): # noinspection PyCallingNonCallable def worker(cat): if cat not in self.categories: - raise RuntimeError('Unsupported category {}. Supported category: {}'. - format(cat, self.categories.keys())) + raise RuntimeError( + 'Unsupported category {}. Supported category: {}'.format( + cat, self.categories.keys() + ) + ) self.logger.info('Searching {} torrents for "{}"'.format(cat, query)) - results.extend(self.categories[cat](query, language=language, *args, **kwargs)) + results.extend( + self.categories[cat](query, *args, language=language, **kwargs) + ) workers = [ threading.Thread(target=worker, kwargs={'cat': category}) @@ -151,11 +171,14 @@ class TorrentPlugin(Plugin): imdb_results = self._imdb_query(query, category) result_queues = [queue.Queue()] * len(imdb_results) workers = [ - threading.Thread(target=self._torrent_search_worker, kwargs={ - 'imdb_id': imdb_results[i]['id'], - 'category': category, - 'q': result_queues[i], - }) + threading.Thread( + target=self._torrent_search_worker, + kwargs={ + 'imdb_id': imdb_results[i]['id'], + 'category': category, + 'q': result_queues[i], + }, + ) for i in range(len(imdb_results)) ] @@ -179,85 +202,99 @@ class TorrentPlugin(Plugin): return results @staticmethod - def _results_to_movies_response(results: List[dict], language: Optional[str] = None): - return sorted([ - { - 'imdb_id': result.get('imdb_id'), - 'type': 'movies', - 'file': item.get('file'), - 'title': '{title} [movies][{language}][{quality}]'.format( - title=result.get('title'), language=lang, quality=quality), - 'duration': int(result.get('runtime'), 0), - 'year': int(result.get('year'), 0), - 'synopsis': result.get('synopsis'), - 'trailer': result.get('trailer'), - 'genres': result.get('genres', []), - 'images': result.get('images', []), - 'rating': result.get('rating', {}), - 'language': lang, - 'quality': quality, - 'size': item.get('size'), - 'provider': item.get('provider'), - 'seeds': item.get('seed'), - 'peers': item.get('peer'), - 'url': item.get('url'), - } - for result in results - for (lang, items) in (result.get('torrents', {}) or {}).items() - if not language or language == lang - for (quality, item) in items.items() - if quality != '0' - ], key=lambda item: item.get('seeds', 0), reverse=True) + def _results_to_movies_response( + results: List[dict], language: Optional[str] = None + ): + return sorted( + [ + { + 'imdb_id': result.get('imdb_id'), + 'type': 'movies', + 'file': item.get('file'), + 'title': '{title} [movies][{language}][{quality}]'.format( + title=result.get('title'), language=lang, quality=quality + ), + 'duration': int(result.get('runtime') or 0), + 'year': int(result.get('year') or 0), + 'synopsis': result.get('synopsis'), + 'trailer': result.get('trailer'), + 'genres': result.get('genres', []), + 'images': result.get('images', []), + 'rating': result.get('rating', {}), + 'language': lang, + 'quality': quality, + 'size': item.get('size'), + 'provider': item.get('provider'), + 'seeds': item.get('seed'), + 'peers': item.get('peer'), + 'url': item.get('url'), + } + for result in results + for (lang, items) in (result.get('torrents', {}) or {}).items() + if not language or language == lang + for (quality, item) in items.items() + if quality != '0' + ], + key=lambda item: item.get('seeds', 0), + reverse=True, + ) @staticmethod def _results_to_tv_response(results: List[dict]): - return sorted([ - { - 'imdb_id': result.get('imdb_id'), - 'tvdb_id': result.get('tvdb_id'), - 'type': 'tv', - 'file': item.get('file'), - 'series': result.get('title'), - 'title': '{series} [S{season:02d}E{episode:02d}] {title} [tv][{quality}]'.format( - series=result.get('title'), - season=episode.get('season'), - episode=episode.get('episode'), - title=episode.get('title'), - quality=quality), - 'duration': int(result.get('runtime'), 0), - 'year': int(result.get('year'), 0), - 'synopsis': result.get('synopsis'), - 'overview': episode.get('overview'), - 'season': episode.get('season'), - 'episode': episode.get('episode'), - 'num_seasons': result.get('num_seasons'), - 'country': result.get('country'), - 'network': result.get('network'), - 'status': result.get('status'), - 'genres': result.get('genres', []), - 'images': result.get('images', []), - 'rating': result.get('rating', {}), - 'quality': quality, - 'provider': item.get('provider'), - 'seeds': item.get('seeds'), - 'peers': item.get('peers'), - 'url': item.get('url'), - } - for result in results - for episode in result.get('episodes', []) - for quality, item in (episode.get('torrents', {}) or {}).items() - if quality != '0' - ], key=lambda item: '{series}.{quality}.{season:02d}.{episode:02d}'.format( - series=item.get('series'), quality=item.get('quality'), - season=item.get('season'), episode=item.get('episode'))) + return sorted( + [ + { + 'imdb_id': result.get('imdb_id'), + 'tvdb_id': result.get('tvdb_id'), + 'type': 'tv', + 'file': item.get('file'), + 'series': result.get('title'), + 'title': '{series} [S{season:02d}E{episode:02d}] {title} [tv][{quality}]'.format( + series=result.get('title'), + season=episode.get('season'), + episode=episode.get('episode'), + title=episode.get('title'), + quality=quality, + ), + 'duration': int(result.get('runtime') or 0), + 'year': int(result.get('year') or 0), + 'synopsis': result.get('synopsis'), + 'overview': episode.get('overview'), + 'season': episode.get('season'), + 'episode': episode.get('episode'), + 'num_seasons': result.get('num_seasons'), + 'country': result.get('country'), + 'network': result.get('network'), + 'status': result.get('status'), + 'genres': result.get('genres', []), + 'images': result.get('images', []), + 'rating': result.get('rating', {}), + 'quality': quality, + 'provider': item.get('provider'), + 'seeds': item.get('seeds'), + 'peers': item.get('peers'), + 'url': item.get('url'), + } + for result in results + for episode in result.get('episodes', []) + for quality, item in (episode.get('torrents', {}) or {}).items() + if quality != '0' + ], + key=lambda item: '{series}.{quality}.{season:02d}.{episode:02d}'.format( + series=item.get('series'), + quality=item.get('quality'), + season=item.get('season'), + episode=item.get('episode'), + ), + ) def search_movies(self, query, language=None): return self._results_to_movies_response( - self._search_torrents(query, 'movies'), language=language) + self._search_torrents(query, 'movies'), language=language + ) def search_tv(self, query, **_): - return self._results_to_tv_response( - self._search_torrents(query, 'tv')) + return self._results_to_tv_response(self._search_torrents(query, 'tv')) def _get_torrent_info(self, torrent, download_dir): import libtorrent as lt @@ -296,7 +333,9 @@ class TorrentPlugin(Plugin): else: torrent_file = os.path.abspath(os.path.expanduser(torrent)) if not os.path.isfile(torrent_file): - raise RuntimeError('{} is not a valid torrent file'.format(torrent_file)) + raise RuntimeError( + '{} is not a valid torrent file'.format(torrent_file) + ) if torrent_file: file_info = lt.torrent_info(torrent_file) @@ -330,7 +369,9 @@ class TorrentPlugin(Plugin): while not transfer.is_finished(): if torrent not in self.transfers: - self.logger.info('Torrent {} has been stopped and removed'.format(torrent)) + self.logger.info( + 'Torrent {} has been stopped and removed'.format(torrent) + ) self._fire_event(TorrentDownloadStopEvent(url=torrent), event_hndl) break @@ -339,14 +380,14 @@ class TorrentPlugin(Plugin): if torrent_file: self.torrent_state[torrent]['size'] = torrent_file.total_size() - files = [os.path.join( - download_dir, - torrent_file.files().file_path(i)) + files = [ + os.path.join(download_dir, torrent_file.files().file_path(i)) for i in range(0, torrent_file.files().num_files()) ] if is_media: from platypush.plugins.media import MediaPlugin + # noinspection PyProtectedMember files = [f for f in files if MediaPlugin.is_video_file(f)] @@ -354,7 +395,9 @@ class TorrentPlugin(Plugin): self.torrent_state[torrent]['name'] = status.name self.torrent_state[torrent]['num_peers'] = status.num_peers self.torrent_state[torrent]['paused'] = status.paused - self.torrent_state[torrent]['progress'] = round(100 * status.progress, 2) + self.torrent_state[torrent]['progress'] = round( + 100 * status.progress, 2 + ) self.torrent_state[torrent]['state'] = status.state.name self.torrent_state[torrent]['title'] = status.name self.torrent_state[torrent]['torrent'] = torrent @@ -363,30 +406,51 @@ class TorrentPlugin(Plugin): self.torrent_state[torrent]['files'] = files if transfer.has_metadata() and not metadata_downloaded: - self._fire_event(TorrentDownloadedMetadataEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentDownloadedMetadataEvent(**self.torrent_state[torrent]), + event_hndl, + ) metadata_downloaded = True if status.state == status.downloading and not download_started: - self._fire_event(TorrentDownloadStartEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentDownloadStartEvent(**self.torrent_state[torrent]), + event_hndl, + ) download_started = True if last_status and status.progress != last_status.progress: - self._fire_event(TorrentDownloadProgressEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentDownloadProgressEvent(**self.torrent_state[torrent]), + event_hndl, + ) if not last_status or status.state != last_status.state: - self._fire_event(TorrentStateChangeEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentStateChangeEvent(**self.torrent_state[torrent]), + event_hndl, + ) if last_status and status.paused != last_status.paused: if status.paused: - self._fire_event(TorrentPausedEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentPausedEvent(**self.torrent_state[torrent]), + event_hndl, + ) else: - self._fire_event(TorrentResumedEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentResumedEvent(**self.torrent_state[torrent]), + event_hndl, + ) last_status = status time.sleep(self._MONITOR_CHECK_INTERVAL) if transfer and transfer.is_finished(): - self._fire_event(TorrentDownloadCompletedEvent(**self.torrent_state[torrent]), event_hndl) + self._fire_event( + TorrentDownloadCompletedEvent(**self.torrent_state[torrent]), + event_hndl, + ) self.remove(torrent) return files @@ -398,12 +462,15 @@ class TorrentPlugin(Plugin): return self._lt_session import libtorrent as lt + # noinspection PyArgumentList self._lt_session = lt.session() return self._lt_session @action - def download(self, torrent, download_dir=None, _async=False, event_hndl=None, is_media=False): + def download( + self, torrent, download_dir=None, _async=False, event_hndl=None, is_media=False + ): """ Download a torrent. @@ -445,10 +512,14 @@ class TorrentPlugin(Plugin): download_dir = os.path.abspath(os.path.expanduser(download_dir)) os.makedirs(download_dir, exist_ok=True) - info, file_info, torrent_file, magnet = self._get_torrent_info(torrent, download_dir) + info, file_info, torrent_file, magnet = self._get_torrent_info( + torrent, download_dir + ) if torrent in self._sessions: - self.logger.info('A torrent session is already running for {}'.format(torrent)) + self.logger.info( + 'A torrent session is already running for {}'.format(torrent) + ) return self.torrent_state.get(torrent, {}) session = self._get_session() @@ -472,12 +543,22 @@ class TorrentPlugin(Plugin): 'title': transfer.status().name, 'trackers': info['trackers'], 'save_path': download_dir, + 'torrent_file': torrent_file, } self._fire_event(TorrentQueuedEvent(url=torrent), event_hndl) - self.logger.info('Downloading "{}" to "{}" from [{}]'.format(info['name'], download_dir, torrent)) - monitor_thread = self._torrent_monitor(torrent=torrent, transfer=transfer, download_dir=download_dir, - event_hndl=event_hndl, is_media=is_media) + self.logger.info( + 'Downloading "{}" to "{}" from [{}]'.format( + info['name'], download_dir, torrent + ) + ) + monitor_thread = self._torrent_monitor( + torrent=torrent, + transfer=transfer, + download_dir=download_dir, + event_hndl=event_hndl, + is_media=is_media, + ) if not _async: return monitor_thread() @@ -565,7 +646,7 @@ class TorrentPlugin(Plugin): @staticmethod def _generate_rand_filename(length=16): name = '' - for i in range(0, length): + for _ in range(0, length): name += hex(random.randint(0, 15))[2:].upper() return name + '.torrent' diff --git a/platypush/plugins/torrent/manifest.yaml b/platypush/plugins/torrent/manifest.yaml index 86adf245..bbddb7f2 100644 --- a/platypush/plugins/torrent/manifest.yaml +++ b/platypush/plugins/torrent/manifest.yaml @@ -2,6 +2,6 @@ manifest: events: {} install: pip: - - python-libtorrent-bin + - python-libtorrent package: platypush.plugins.torrent type: plugin diff --git a/requirements.txt b/requirements.txt index 16b74f21..a78946b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,6 @@ bcrypt croniter flask frozendict -gunicorn marshmallow marshmallow_dataclass paho-mqtt @@ -18,10 +17,9 @@ pyyaml redis requests rsa -simple_websocket sqlalchemy +tornado tz -uvicorn websocket-client -wsproto +websockets zeroconf>=0.27.0 diff --git a/setup.py b/setup.py index 7f5712ac..48f65213 100755 --- a/setup.py +++ b/setup.py @@ -65,7 +65,6 @@ setup( 'croniter', 'flask', 'frozendict', - 'gunicorn', 'marshmallow', 'marshmallow_dataclass', 'python-dateutil', @@ -74,13 +73,12 @@ setup( 'redis', 'requests', 'rsa', - 'simple_websocket', 'sqlalchemy', + 'tornado', 'tz', - 'uvicorn', 'websocket-client', + 'websockets', 'wheel', - 'wsproto', 'zeroconf>=0.27.0', ], extras_require={ @@ -109,6 +107,7 @@ setup( 'google-tts': [ 'oauth2client', 'google-api-python-client', + 'google-auth', 'google-cloud-texttospeech', ], # Support for OMXPlayer plugin @@ -116,7 +115,7 @@ setup( # Support for YouTube 'youtube': ['youtube-dl'], # Support for torrents download - 'torrent': ['python-libtorrent-bin'], + 'torrent': ['python-libtorrent'], # Generic support for cameras 'camera': ['numpy', 'Pillow'], # Support for RaspberryPi camera @@ -124,10 +123,10 @@ setup( # Support for inotify file monitors 'inotify': ['inotify'], # Support for Google Assistant - 'google-assistant-legacy': ['google-assistant-library'], - 'google-assistant': ['google-assistant-sdk[samples]'], + 'google-assistant-legacy': ['google-assistant-library', 'google-auth'], + 'google-assistant': ['google-assistant-sdk[samples]', 'google-auth'], # Support for the Google APIs - 'google': ['oauth2client', 'google-api-python-client'], + 'google': ['oauth2client', 'google-auth', 'google-api-python-client'], # Support for Last.FM scrobbler plugin 'lastfm': ['pylast'], # Support for custom hotword detection @@ -171,7 +170,7 @@ setup( # Support for BME280 environment sensor 'bme280': ['pimoroni-bme280'], # Support for LTR559 light/proximity sensor - 'ltr559': ['ltr559'], + 'ltr559': ['ltr559', 'smbus'], # Support for VL53L1X laser ranger/distance sensor 'vl53l1x': ['smbus2', 'vl53l1x'], # Support for Dropbox integration @@ -212,9 +211,9 @@ setup( # Support for Trello integration 'trello': ['py-trello'], # Support for Google Pub/Sub - 'google-pubsub': ['google-cloud-pubsub'], + 'google-pubsub': ['google-cloud-pubsub', 'google-auth'], # Support for Google Translate - 'google-translate': ['google-cloud-translate'], + 'google-translate': ['google-cloud-translate', 'google-auth'], # Support for keyboard/mouse plugin 'inputs': ['pyuserinput'], # Support for Buienradar weather forecast