diff --git a/docs/source/conf.py b/docs/source/conf.py
index 900c617c3..b1b9a0a6c 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 0b763e4e7..1fe7fb312 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 1668b511c..410f231c7 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 b48cf3c6a..793fcadd9 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 6fc4dab1b..922ad7818 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 d0bc4be35..000000000
--- 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 bac62a873..2dcbee8a7 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 a2798e05d..000000000
--- 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 fe1ca3d42..c481b77a2 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 90f8ef5b0..f8259dfc0 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 12df01184..130a4201a 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 13afb59c9..d1a511ca3 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 2f3745079..efcd1a89f 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 14a1d5fea..bb745b3c8 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 9eec16996..43e2cae48 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 f73f0f58c..c1b373d40 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 13fc150fd..6114d3a2e 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 abd723fff..390b81721 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 fe0472905..1a7df968c 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 227a36ec3..2af250c2b 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 569b3c9c0..f546ba3c4 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 561878caf..bee4f43d3 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 a08ca8f56..94ee6d67e 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 a2abe0962..679498570 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 557a65b74..44e6bbf57 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 bdc0c567b..8e50d7454 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 1d832d6cb..fa8e2c61a 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 840ffed6f..35befb6f1 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 6ec27a592..b2efad9c1 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 682f9f3d1..713707954 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 86adf245a..bbddb7f22 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 16b74f211..a78946b42 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 7f5712ac4..48f65213a 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