From b0cc80ceb0820603f4d6f9d1f39df35583e919b6 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <fabio@manganiello.tech>
Date: Fri, 10 Feb 2023 17:40:20 +0100
Subject: [PATCH] Rewriting `bluetooth.ble` plugin to use `bleak` instead of
 `gattlib`.

---
 docs/source/backends.rst                      |   2 -
 docs/source/events.rst                        |   1 +
 .../backend/bluetooth.scanner.ble.rst         |   5 -
 docs/source/platypush/backend/zigbee.mqtt.rst |   5 -
 .../source/platypush/events/bluetooth.ble.rst |   5 +
 .../backend/bluetooth/scanner/ble/__init__.py |  33 --
 .../bluetooth/scanner/ble/manifest.yaml       |  10 -
 platypush/entities/__init__.py                |   7 +-
 platypush/entities/_managers/__init__.py      |   6 +-
 platypush/entities/_managers/switches.py      |   2 +-
 platypush/message/event/bluetooth.py          |  74 ---
 platypush/message/event/bluetooth/__init__.py |  75 +++
 platypush/message/event/bluetooth/ble.py      | 104 ++++
 platypush/plugins/__init__.py                 |  10 +-
 platypush/plugins/bluetooth/ble/__init__.py   | 517 ++++++++++--------
 platypush/plugins/bluetooth/ble/manifest.yaml |   3 +-
 .../plugins/switchbot/bluetooth/__init__.py   | 184 ++-----
 17 files changed, 538 insertions(+), 505 deletions(-)
 delete mode 100644 docs/source/platypush/backend/bluetooth.scanner.ble.rst
 delete mode 100644 docs/source/platypush/backend/zigbee.mqtt.rst
 create mode 100644 docs/source/platypush/events/bluetooth.ble.rst
 delete mode 100644 platypush/backend/bluetooth/scanner/ble/__init__.py
 delete mode 100644 platypush/backend/bluetooth/scanner/ble/manifest.yaml
 delete mode 100644 platypush/message/event/bluetooth.py
 create mode 100644 platypush/message/event/bluetooth/__init__.py
 create mode 100644 platypush/message/event/bluetooth/ble.py

diff --git a/docs/source/backends.rst b/docs/source/backends.rst
index 74b225f60..995fd8dd5 100644
--- a/docs/source/backends.rst
+++ b/docs/source/backends.rst
@@ -13,7 +13,6 @@ Backends
     platypush/backend/bluetooth.fileserver.rst
     platypush/backend/bluetooth.pushserver.rst
     platypush/backend/bluetooth.scanner.rst
-    platypush/backend/bluetooth.scanner.ble.rst
     platypush/backend/button.flic.rst
     platypush/backend/camera.pi.rst
     platypush/backend/chat.telegram.rst
@@ -74,6 +73,5 @@ Backends
     platypush/backend/weather.openweathermap.rst
     platypush/backend/websocket.rst
     platypush/backend/wiimote.rst
-    platypush/backend/zigbee.mqtt.rst
     platypush/backend/zwave.rst
     platypush/backend/zwave.mqtt.rst
diff --git a/docs/source/events.rst b/docs/source/events.rst
index 8c4d13068..28df635ca 100644
--- a/docs/source/events.rst
+++ b/docs/source/events.rst
@@ -11,6 +11,7 @@ Events
     platypush/events/application.rst
     platypush/events/assistant.rst
     platypush/events/bluetooth.rst
+    platypush/events/bluetooth.ble.rst
     platypush/events/button.flic.rst
     platypush/events/camera.rst
     platypush/events/chat.slack.rst
diff --git a/docs/source/platypush/backend/bluetooth.scanner.ble.rst b/docs/source/platypush/backend/bluetooth.scanner.ble.rst
deleted file mode 100644
index ed8f59c94..000000000
--- a/docs/source/platypush/backend/bluetooth.scanner.ble.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``bluetooth.scanner.ble``
-===========================================
-
-.. automodule:: platypush.backend.bluetooth.scanner.ble
-    :members:
diff --git a/docs/source/platypush/backend/zigbee.mqtt.rst b/docs/source/platypush/backend/zigbee.mqtt.rst
deleted file mode 100644
index dd524f14d..000000000
--- a/docs/source/platypush/backend/zigbee.mqtt.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-``zigbee.mqtt``
-=================================
-
-.. automodule:: platypush.backend.zigbee.mqtt
-    :members:
diff --git a/docs/source/platypush/events/bluetooth.ble.rst b/docs/source/platypush/events/bluetooth.ble.rst
new file mode 100644
index 000000000..c2573a0cc
--- /dev/null
+++ b/docs/source/platypush/events/bluetooth.ble.rst
@@ -0,0 +1,5 @@
+``bluetooth.ble``
+=================
+
+.. automodule:: platypush.message.event.bluetooth.ble
+    :members:
diff --git a/platypush/backend/bluetooth/scanner/ble/__init__.py b/platypush/backend/bluetooth/scanner/ble/__init__.py
deleted file mode 100644
index 30e1d6257..000000000
--- a/platypush/backend/bluetooth/scanner/ble/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from typing import Optional
-
-from platypush.backend.bluetooth.scanner import BluetoothScannerBackend
-
-
-class BluetoothBleScannerBackend(BluetoothScannerBackend):
-    """
-    This backend periodically scans for available bluetooth low-energy devices and returns events when a devices enter
-    or exits the range.
-
-    Triggers:
-
-        * :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent` when a new bluetooth device is found.
-        * :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent` when a bluetooth device is lost.
-
-    Requires:
-
-        * The :class:`platypush.plugins.bluetooth.BluetoothBlePlugin` plugin working.
-
-    """
-
-    def __init__(self, interface: Optional[int] = None, scan_duration: int = 10, **kwargs):
-        """
-        :param interface: Bluetooth adapter name to use (default configured on the ``bluetooth.ble`` plugin if None).
-        :param scan_duration:  How long the scan should run (default: 10 seconds).
-        """
-        super().__init__(plugin='bluetooth.ble', plugin_args={
-            'interface': interface,
-            'duration': scan_duration,
-        }, **kwargs)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/backend/bluetooth/scanner/ble/manifest.yaml b/platypush/backend/bluetooth/scanner/ble/manifest.yaml
deleted file mode 100644
index 65be3ca9b..000000000
--- a/platypush/backend/bluetooth/scanner/ble/manifest.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-manifest:
-  events:
-    platypush.message.event.bluetooth.BluetoothDeviceFoundEvent: when a new bluetooth
-      device is found.
-    platypush.message.event.bluetooth.BluetoothDeviceLostEvent: when a bluetooth device
-      is lost.
-  install:
-    pip: []
-  package: platypush.backend.bluetooth.scanner.ble
-  type: backend
diff --git a/platypush/entities/__init__.py b/platypush/entities/__init__.py
index 5c9e7a703..d51b85391 100644
--- a/platypush/entities/__init__.py
+++ b/platypush/entities/__init__.py
@@ -3,7 +3,11 @@ from typing import Collection, Optional
 
 from ._base import Entity, get_entities_registry, init_entities_db
 from ._engine import EntitiesEngine
-from ._managers import register_entity_manager, get_plugin_entity_registry
+from ._managers import (
+    EntityManager,
+    get_plugin_entity_registry,
+    register_entity_manager,
+)
 from ._managers.lights import LightEntityManager
 from ._managers.sensors import SensorEntityManager
 from ._managers.switches import (
@@ -50,6 +54,7 @@ __all__ = (
     'DimmerEntityManager',
     'EntitiesEngine',
     'Entity',
+    'EntityManager',
     'EnumSwitchEntityManager',
     'LightEntityManager',
     'SensorEntityManager',
diff --git a/platypush/entities/_managers/__init__.py b/platypush/entities/_managers/__init__.py
index e08cec7f0..a70ca83c9 100644
--- a/platypush/entities/_managers/__init__.py
+++ b/platypush/entities/_managers/__init__.py
@@ -109,11 +109,7 @@ def register_entity_manager(cls: Type[EntityManager]):
     Associates a plugin as a manager for a certain entity type.
     You usually don't have to call this method directly.
     """
-    entity_managers = [
-        c
-        for c in inspect.getmro(cls)
-        if issubclass(c, EntityManager) and c not in {cls, EntityManager}
-    ]
+    entity_managers = [c for c in inspect.getmro(cls) if issubclass(c, EntityManager)]
 
     plugin_name = get_plugin_name_by_class(cls) or ''
     redis = get_redis()
diff --git a/platypush/entities/_managers/switches.py b/platypush/entities/_managers/switches.py
index cd78d13da..eef80f0f7 100644
--- a/platypush/entities/_managers/switches.py
+++ b/platypush/entities/_managers/switches.py
@@ -35,7 +35,7 @@ class MultiLevelSwitchEntityManager(EntityManager, ABC):
 
     @abstractmethod
     def set_value(  # pylint: disable=redefined-builtin
-        self, *_, property=None, data=None, **__
+        self, device=None, property=None, *, data=None, **__
     ):
         """Set a value"""
         raise NotImplementedError()
diff --git a/platypush/message/event/bluetooth.py b/platypush/message/event/bluetooth.py
deleted file mode 100644
index b70f68489..000000000
--- a/platypush/message/event/bluetooth.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from typing import Optional
-
-from platypush.message.event import Event
-
-
-class BluetoothEvent(Event):
-    pass
-
-
-class BluetoothDeviceFoundEvent(Event):
-    """
-    Event triggered when a bluetooth device is found during a scan.
-    """
-    def __init__(self, address: str, name: Optional[str] = None, *args, **kwargs):
-        super().__init__(*args, address=address, name=name, **kwargs)
-
-
-class BluetoothDeviceLostEvent(Event):
-    """
-    Event triggered when a bluetooth device previously scanned is lost.
-    """
-    def __init__(self, address: str, name: Optional[str] = None, *args, **kwargs):
-        super().__init__(*args, address=address, name=name, **kwargs)
-
-
-class BluetoothDeviceConnectedEvent(Event):
-    """
-    Event triggered on bluetooth device connection
-    """
-    def __init__(self, address: str = None, port: str = None, *args, **kwargs):
-        super().__init__(*args, address=address, port=port, **kwargs)
-
-
-class BluetoothDeviceDisconnectedEvent(Event):
-    """
-    Event triggered on bluetooth device disconnection
-    """
-    def __init__(self, address: str = None, port: str = None, *args, **kwargs):
-        super().__init__(*args, address=address, port=port, **kwargs)
-
-
-class BluetoothConnectionRejectedEvent(Event):
-    """
-    Event triggered on bluetooth device connection rejected
-    """
-    def __init__(self, address: str = None, port: str = None, *args, **kwargs):
-        super().__init__(*args, address=address, port=port, **kwargs)
-
-
-class BluetoothFilePutRequestEvent(Event):
-    """
-    Event triggered on bluetooth device file transfer put request
-    """
-    def __init__(self, address: str = None, port: str = None, *args, **kwargs):
-        super().__init__(*args, address=address, port=port, **kwargs)
-
-
-class BluetoothFileGetRequestEvent(Event):
-    """
-    Event triggered on bluetooth device file transfer get request
-    """
-    def __init__(self, address: str = None, port: str = None, *args, **kwargs):
-        super().__init__(*args, address=address, port=port, **kwargs)
-
-
-class BluetoothFileReceivedEvent(Event):
-    """
-    Event triggered on bluetooth device file transfer put request
-    """
-    def __init__(self, path: str = None, *args, **kwargs):
-        super().__init__(*args, path=path, **kwargs)
-
-
-# vim:sw=4:ts=4:et:
diff --git a/platypush/message/event/bluetooth/__init__.py b/platypush/message/event/bluetooth/__init__.py
new file mode 100644
index 000000000..1e55f4cef
--- /dev/null
+++ b/platypush/message/event/bluetooth/__init__.py
@@ -0,0 +1,75 @@
+from typing import Optional
+
+from platypush.message.event import Event
+
+
+class BluetoothEvent(Event):
+    """
+    Base class for Bluetooth events.
+    """
+
+    def __init__(self, address: str, *args, name: Optional[str] = None, **kwargs):
+        super().__init__(*args, address=address, name=name, **kwargs)
+
+
+class BluetoothWithPortEvent(BluetoothEvent):
+    """
+    Base class for Bluetooth events that include a communication port.
+    """
+
+    def __init__(self, *args, port: Optional[str] = None, **kwargs):
+        super().__init__(*args, port=port, **kwargs)
+
+
+class BluetoothDeviceFoundEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is found during a scan.
+    """
+
+
+class BluetoothDeviceLostEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device previously scanned is lost.
+    """
+
+
+class BluetoothDeviceConnectedEvent(BluetoothWithPortEvent):
+    """
+    Event triggered when a Bluetooth device is connected.
+    """
+
+
+class BluetoothDeviceDisconnectedEvent(BluetoothWithPortEvent):
+    """
+    Event triggered when a Bluetooth device is disconnected.
+    """
+
+
+class BluetoothConnectionRejectedEvent(BluetoothWithPortEvent):
+    """
+    Event triggered when a Bluetooth connection is rejected.
+    """
+
+
+class BluetoothFilePutRequestEvent(BluetoothWithPortEvent):
+    """
+    Event triggered when a file put request is received.
+    """
+
+
+class BluetoothFileGetRequestEvent(BluetoothWithPortEvent):
+    """
+    Event triggered when a file get request is received.
+    """
+
+
+class BluetoothFileReceivedEvent(BluetoothEvent):
+    """
+    Event triggered when a file transfer is completed.
+    """
+
+    def __init__(self, *args, path: str, **kwargs):
+        super().__init__(*args, path=path, **kwargs)
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/message/event/bluetooth/ble.py b/platypush/message/event/bluetooth/ble.py
new file mode 100644
index 000000000..5289e6b95
--- /dev/null
+++ b/platypush/message/event/bluetooth/ble.py
@@ -0,0 +1,104 @@
+from typing import Collection, Optional
+
+from platypush.message.event import Event
+
+
+class BluetoothEvent(Event):
+    """
+    Base class for Bluetooth Low-Energy device events.
+    """
+
+    def __init__(
+        self,
+        *args,
+        address: str,
+        connected: bool,
+        paired: bool,
+        trusted: bool,
+        blocked: bool,
+        name: Optional[str] = None,
+        service_uuids: Optional[Collection[str]] = None,
+        **kwargs
+    ):
+        """
+        :param address: The Bluetooth address of the device.
+        :param connected: Whether the device is connected.
+        :param paired: Whether the device is paired.
+        :param trusted: Whether the device is trusted.
+        :param blocked: Whether the device is blocked.
+        :param name: The name of the device.
+        :param service_uuids: The service UUIDs of the device.
+        """
+        super().__init__(
+            *args,
+            address=address,
+            name=name,
+            connected=connected,
+            paired=paired,
+            blocked=blocked,
+            service_uuids=service_uuids or [],
+            **kwargs
+        )
+
+
+class BluetoothDeviceFoundEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is discovered during a scan.
+    """
+
+
+class BluetoothDeviceLostEvent(BluetoothEvent):
+    """
+    Event triggered when a previously discovered Bluetooth device is lost.
+    """
+
+
+class BluetoothDeviceConnectedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is connected.
+    """
+
+
+class BluetoothDeviceDisconnectedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is disconnected.
+    """
+
+
+class BluetoothDevicePairedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is paired.
+    """
+
+
+class BluetoothDeviceUnpairedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is unpaired.
+    """
+
+
+class BluetoothDeviceBlockedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is blocked.
+    """
+
+
+class BluetoothDeviceUnblockedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is unblocked.
+    """
+
+
+class BluetoothDeviceTrustedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is trusted.
+    """
+
+
+class BluetoothDeviceUntrustedEvent(BluetoothEvent):
+    """
+    Event triggered when a Bluetooth device is untrusted.
+    """
+
+
+# vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/__init__.py b/platypush/plugins/__init__.py
index a3fb325cd..a26b13f95 100644
--- a/platypush/plugins/__init__.py
+++ b/platypush/plugins/__init__.py
@@ -206,9 +206,13 @@ class AsyncRunnablePlugin(RunnablePlugin, ABC):
         self._loop = asyncio.new_event_loop()
 
         if self._should_start_runner:
-            self._run_listener()
-
-        self.wait_stop()
+            while not self.should_stop():
+                try:
+                    self._run_listener()
+                finally:
+                    self.wait_stop(self.poll_interval)
+        else:
+            self.wait_stop()
 
     def stop(self):
         if self._loop and self._loop.is_running():
diff --git a/platypush/plugins/bluetooth/ble/__init__.py b/platypush/plugins/bluetooth/ble/__init__.py
index 623606acf..ebb65ad74 100644
--- a/platypush/plugins/bluetooth/ble/__init__.py
+++ b/platypush/plugins/bluetooth/ble/__init__.py
@@ -1,275 +1,352 @@
 import base64
-import os
-import subprocess
-import sys
-import time
-from typing import Optional, Dict
+from contextlib import asynccontextmanager
+from threading import RLock
+from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union
+from uuid import UUID
 
-from platypush.plugins import action
-from platypush.plugins.sensor import SensorPlugin
-from platypush.message.response.bluetooth import BluetoothScanResponse, BluetoothDiscoverPrimaryResponse, \
-    BluetoothDiscoverCharacteristicsResponse
+from bleak import BleakClient, BleakScanner
+from bleak.backends.device import BLEDevice
+from typing_extensions import override
+
+from platypush.context import get_bus, get_or_create_event_loop
+from platypush.entities import Entity, EntityManager
+from platypush.entities.devices import Device
+from platypush.message.event.bluetooth.ble import (
+    BluetoothDeviceBlockedEvent,
+    BluetoothDeviceConnectedEvent,
+    BluetoothDeviceDisconnectedEvent,
+    BluetoothDeviceFoundEvent,
+    BluetoothDeviceLostEvent,
+    BluetoothDevicePairedEvent,
+    BluetoothDeviceTrustedEvent,
+    BluetoothDeviceUnblockedEvent,
+    BluetoothDeviceUnpairedEvent,
+    BluetoothDeviceUntrustedEvent,
+    BluetoothEvent,
+)
+from platypush.plugins import AsyncRunnablePlugin, action
+
+UUIDType = Union[str, UUID]
 
 
-class BluetoothBlePlugin(SensorPlugin):
+class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
     """
-    Bluetooth BLE (low-energy) plugin
+    Plugin to interact with BLE (Bluetooth Low-Energy) devices.
+
+    Note that the support for Bluetooth low-energy devices requires a Bluetooth
+    adapter compatible with the Bluetooth 5.0 specification or higher.
 
     Requires:
 
-        * **pybluez** (``pip install pybluez``)
-        * **gattlib** (``pip install gattlib``)
+        * **bleak** (``pip install bleak``)
 
-    Note that the support for bluetooth low-energy devices on Linux requires:
-
-        * A bluetooth adapter compatible with the bluetooth 5.0 specification or higher;
-        * To run platypush with root privileges (which is usually a very bad idea), or to set the raw net
-          capabilities on the Python executable (which is also a bad idea, because any Python script will
-          be able to access the kernel raw network API, but it's probably better than running a network
-          server that can execute system commands as root user). If you don't want to set special permissions
-          on the main Python executable and you want to run the bluetooth LTE plugin then the advised approach
-          is to install platypush in a virtual environment and set the capabilities on the venv python executable,
-          or run your platypush instance in Docker.
-
-          You can set the capabilities on the Python executable through the following shell command::
-
-            [sudo] setcap 'cap_net_raw,cap_net_admin+eip' /path/to/your/python
+    TODO: Write supported events.
 
     """
 
-    def __init__(self, interface: str = 'hci0', **kwargs):
+    # Default connection timeout (in seconds)
+    _default_connect_timeout = 5
+
+    def __init__(
+        self,
+        interface: Optional[str] = None,
+        connect_timeout: float = _default_connect_timeout,
+        device_names: Optional[Dict[str, str]] = None,
+        service_uuids: Optional[Collection[UUIDType]] = None,
+        **kwargs,
+    ):
         """
-        :param interface: Default adapter device to be used (default: 'hci0')
+        :param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
+            on Linux). Default: first available interface.
+        :param connect_timeout: Timeout in seconds for the connection to a
+            Bluetooth device. Default: 5 seconds.
+        :param service_uuids: List of service UUIDs to discover. Default: all.
+        :param device_names: Bluetooth address -> device name mapping. If not
+            specified, the device's advertised name will be used, or its
+            Bluetooth address. Example:
+
+                .. code-block:: json
+
+                    {
+                        "00:11:22:33:44:55": "Switchbot",
+                        "00:11:22:33:44:56": "Headphones",
+                        "00:11:22:33:44:57": "Button"
+                    }
+
         """
         super().__init__(**kwargs)
-        self.interface = interface
-        self._req_by_addr = {}
 
-    @staticmethod
-    def _get_python_interpreter() -> str:
-        exe = sys.executable
+        self._interface = interface
+        self._connect_timeout = connect_timeout
+        self._service_uuids = service_uuids
+        self._scan_lock = RLock()
+        self._connections: Dict[str, BleakClient] = {}
+        self._devices: Dict[str, BLEDevice] = {}
+        self._device_name_by_addr = device_names or {}
+        self._device_addr_by_name = {
+            name: addr for addr, name in self._device_name_by_addr.items()
+        }
 
-        while os.path.islink(exe):
-            target = os.readlink(exe)
-            if not os.path.isabs(target):
-                target = os.path.abspath(os.path.join(os.path.dirname(exe), target))
-            exe = target
-
-        return exe
-
-    @staticmethod
-    def _python_has_ble_capabilities(exe: str) -> bool:
-        getcap = subprocess.Popen(['getcap', exe], stdout=subprocess.PIPE)
-        output = getcap.communicate()[0].decode().split('\n')
-        if not output:
-            return False
-
-        caps = output[0]
-        return ('cap_net_raw+eip' in caps or 'cap_net_raw=eip' in caps) and 'cap_net_admin' in caps
-
-    def _check_ble_support(self):
-        # Check if the script is running as root or if the Python executable
-        # has 'cap_net_admin,cap_net_raw+eip' capabilities
-        exe = self._get_python_interpreter()
-        assert os.getuid() == 0 or self._python_has_ble_capabilities(exe), '''
-            You are not running platypush as root and the Python interpreter has no
-            capabilities/permissions to access the BLE stack. Set the permissions on
-            your Python interpreter through:
-
-                [sudo] setcap "cap_net_raw,cap_net_admin+eip" {}'''.format(exe)
-
-    @action
-    def scan(self, interface: Optional[str] = None, duration: int = 10) -> BluetoothScanResponse:
+    async def _get_device(self, device: str) -> BLEDevice:
         """
-        Scan for nearby bluetooth low-energy devices
-
-        :param interface: Bluetooth adapter name to use (default configured if None)
-        :param duration: Scan duration in seconds
+        Utility method to get a device by name or address.
         """
-        from bluetooth.ble import DiscoveryService
+        addr = (
+            self._device_addr_by_name[device]
+            if device in self._device_addr_by_name
+            else device
+        )
 
-        if interface is None:
-            interface = self.interface
+        if addr not in self._devices:
+            self.logger.info('Scanning for unknown device "%s"', device)
+            await self._scan()
 
-        self._check_ble_support()
-        svc = DiscoveryService(interface)
-        devices = svc.discover(duration)
-        return BluetoothScanResponse(devices)
+        dev = self._devices.get(addr)
+        assert dev is not None, f'Unknown device: "{device}"'
+        return dev
 
-    @action
-    def get_measurement(self, interface: Optional[str] = None, duration: Optional[int] = 10, *args, **kwargs) \
-            -> Dict[str, dict]:
-        """
-        Wrapper for ``scan`` that returns bluetooth devices in a format usable by sensor backends.
+    def _get_device_name(self, device: BLEDevice) -> str:
+        return (
+            self._device_name_by_addr.get(device.address)
+            or device.name
+            or device.address
+        )
 
-        :param interface: Bluetooth adapter name to use (default configured if None)
-        :param duration: Scan duration in seconds
-        :return: Device address -> info map.
-        """
-        devices = self.scan(interface=interface, duration=duration).output
-        return {device['addr']: device for device in devices}
+    def _post_event(
+        self, event_type: Type[BluetoothEvent], device: BLEDevice, **kwargs
+    ):
+        props = device.details.get('props', {})
+        get_bus().post(
+            event_type(
+                address=device.address,
+                name=self._get_device_name(device),
+                connected=props.get('Connected', False),
+                paired=props.get('Paired', False),
+                blocked=props.get('Blocked', False),
+                trusted=props.get('Trusted', False),
+                service_uuids=device.metadata.get('uuids', []),
+                **kwargs,
+            )
+        )
 
-    # noinspection PyArgumentList
-    @action
-    def connect(self, device: str, interface: str = None, wait: bool = True, channel_type: str = 'public',
-                security_level: str = 'low', psm: int = 0, mtu: int = 0, timeout: float = 10.0):
-        """
-        Connect to a bluetooth LE device
+    def _on_device_event(self, device: BLEDevice, _):
+        event_types: List[Type[BluetoothEvent]] = []
+        existing_device = self._devices.get(device.address)
 
-        :param device: Device address to connect to
-        :param interface: Bluetooth adapter name to use (default configured if None)
-        :param wait: If True then wait for the connection to be established before returning (no timeout)
-        :param channel_type: Channel type, usually 'public' or 'random'
-        :param security_level: Security level - possible values: ['low', 'medium', 'high']
-        :param psm: PSM value (default: 0)
-        :param mtu: MTU value (default: 0)
-        :param timeout: Connection timeout if wait is not set (default: 10 seconds)
-        """
-        from gattlib import GATTRequester
+        if existing_device:
+            old_props = existing_device.details.get('props', {})
+            new_props = device.details.get('props', {})
 
-        req = self._req_by_addr.get(device)
-        if req:
-            if req.is_connected():
-                self.logger.info('Device {} is already connected'.format(device))
-                return
+            if old_props.get('Paired') != new_props.get('Paired'):
+                event_types.append(
+                    BluetoothDevicePairedEvent
+                    if new_props.get('Paired')
+                    else BluetoothDeviceUnpairedEvent
+                )
 
-            self._req_by_addr[device] = None
+            if old_props.get('Connected') != new_props.get('Connected'):
+                event_types.append(
+                    BluetoothDeviceConnectedEvent
+                    if new_props.get('Connected')
+                    else BluetoothDeviceDisconnectedEvent
+                )
 
-        if not interface:
-            interface = self.interface
-        if interface:
-            req = GATTRequester(device, False, interface)
+            if old_props.get('Blocked') != new_props.get('Blocked'):
+                event_types.append(
+                    BluetoothDeviceBlockedEvent
+                    if new_props.get('Blocked')
+                    else BluetoothDeviceUnblockedEvent
+                )
+
+            if old_props.get('Trusted') != new_props.get('Trusted'):
+                event_types.append(
+                    BluetoothDeviceTrustedEvent
+                    if new_props.get('Trusted')
+                    else BluetoothDeviceUntrustedEvent
+                )
         else:
-            req = GATTRequester(device, False)
+            event_types.append(BluetoothDeviceFoundEvent)
 
-        self.logger.info('Connecting to {}'.format(device))
-        connect_start_time = time.time()
-        req.connect(wait, channel_type, security_level, psm, mtu)
+        self._devices[device.address] = device
 
-        if not wait:
-            while not req.is_connected():
-                if time.time() - connect_start_time > timeout:
-                    raise TimeoutError('Connection to {} timed out'.format(device))
-                time.sleep(0.1)
+        if event_types:
+            for event_type in event_types:
+                self._post_event(event_type, device)
+            self.publish_entities([device])
 
-        self.logger.info('Connected to {}'.format(device))
-        self._req_by_addr[device] = req
+    @asynccontextmanager
+    async def _connect(
+        self,
+        device: str,
+        interface: Optional[str] = None,
+        timeout: Optional[float] = None,
+    ) -> AsyncGenerator[BleakClient, None]:
+        dev = await self._get_device(device)
+        async with BleakClient(
+            dev.address,
+            adapter=interface or self._interface,
+            timeout=timeout or self._connect_timeout,
+        ) as client:
+            self._connections[dev.address] = client
+            yield client
+            self._connections.pop(dev.address)
 
-    @action
-    def read(self, device: str, interface: str = None, uuid: str = None, handle: int = None,
-             binary: bool = False, disconnect_on_recv: bool = True, **kwargs) -> str:
-        """
-        Read a message from a device
-
-        :param device: Device address to connect to
-        :param interface: Bluetooth adapter name to use (default configured if None)
-        :param uuid: Service UUID. Either the UUID or the device handle must be specified
-        :param handle: Device handle. Either the UUID or the device handle must be specified
-        :param binary: Set to true to return data as a base64-encoded binary string
-        :param disconnect_on_recv: If True (default) disconnect when the response is received
-        :param kwargs: Extra arguments to be passed to :meth:`connect`
-        """
-        if interface is None:
-            interface = self.interface
-        if not (uuid or handle):
-            raise AttributeError('Specify either uuid or handle')
-
-        self.connect(device, interface=interface, **kwargs)
-        req = self._req_by_addr[device]
-
-        if uuid:
-            data = req.read_by_uuid(uuid)[0]
-        else:
-            data = req.read_by_handle(handle)[0]
-
-        if binary:
-            data = base64.encodebytes(data.encode() if isinstance(data, str) else data).decode().strip()
-        if disconnect_on_recv:
-            self.disconnect(device)
+    async def _read(
+        self,
+        device: str,
+        service_uuid: UUIDType,
+        interface: Optional[str] = None,
+        connect_timeout: Optional[float] = None,
+    ) -> bytearray:
+        async with self._connect(device, interface, connect_timeout) as client:
+            data = await client.read_gatt_char(service_uuid)
 
         return data
 
+    async def _write(
+        self,
+        device: str,
+        data: bytes,
+        service_uuid: UUIDType,
+        interface: Optional[str] = None,
+        connect_timeout: Optional[float] = None,
+    ):
+        async with self._connect(device, interface, connect_timeout) as client:
+            await client.write_gatt_char(service_uuid, data)
+
+    async def _scan(
+        self,
+        duration: Optional[float] = None,
+        service_uuids: Optional[Collection[UUIDType]] = None,
+        publish_entities: bool = False,
+    ) -> Collection[Entity]:
+        with self._scan_lock:
+            timeout = duration or self.poll_interval or 5
+            devices = await BleakScanner.discover(
+                adapter=self._interface,
+                timeout=timeout,
+                service_uuids=list(
+                    map(str, service_uuids or self._service_uuids or [])
+                ),
+                detection_callback=self._on_device_event,
+            )
+
+            # TODO Infer type from device.metadata['manufacturer_data']
+
+        self._devices.update({dev.address: dev for dev in devices})
+        if publish_entities:
+            entities = self.publish_entities(devices)
+        else:
+            entities = self.transform_entities(devices)
+
+        return entities
+
     @action
-    def write(self, device: str, data, handle: int = None, interface: str = None, binary: bool = False,
-              disconnect_on_recv: bool = True, **kwargs) -> str:
+    def scan(
+        self,
+        duration: Optional[float] = None,
+        service_uuids: Optional[Collection[UUIDType]] = None,
+    ):
+        """
+        Scan for Bluetooth devices nearby.
+
+        :param duration: Scan duration in seconds (default: same as the plugin's
+            `poll_interval` configuration parameter)
+        :param service_uuids: List of service UUIDs to discover. Default: all.
+        """
+        loop = get_or_create_event_loop()
+        loop.run_until_complete(
+            self._scan(duration, service_uuids, publish_entities=True)
+        )
+
+    @action
+    def read(
+        self,
+        device: str,
+        service_uuid: UUIDType,
+        interface: Optional[str] = None,
+        connect_timeout: Optional[float] = None,
+    ) -> str:
+        """
+        Read a message from a device.
+
+        :param device: Name or address of the device to read from.
+        :param service_uuid: Service UUID.
+        :param interface: Bluetooth adapter name to use (default configured if None).
+        :param connect_timeout: Connection timeout in seconds (default: same as the
+            configured `connect_timeout`).
+        :return: The base64-encoded response received from the device.
+        """
+        loop = get_or_create_event_loop()
+        data = loop.run_until_complete(
+            self._read(device, service_uuid, interface, connect_timeout)
+        )
+        return base64.b64encode(data).decode()
+
+    @action
+    def write(
+        self,
+        device: str,
+        data: Union[str, bytes],
+        service_uuid: UUIDType,
+        interface: Optional[str] = None,
+        connect_timeout: Optional[float] = None,
+    ):
         """
         Writes data to a device
 
-        :param device: Device address to connect to
-        :param data: Data to be written (str or bytes)
+        :param device: Name or address of the device to read from.
+        :param data: Data to be written, either as bytes or as a base64-encoded string.
+        :param service_uuid: Service UUID.
         :param interface: Bluetooth adapter name to use (default configured if None)
-        :param handle: Device handle. Either the UUID or the device handle must be specified
-        :param binary: Set to true if data is a base64-encoded binary string
-        :param disconnect_on_recv: If True (default) disconnect when the response is received
-        :param kwargs: Extra arguments to be passed to :meth:`connect`
+        :param connect_timeout: Connection timeout in seconds (default: same as the
+            configured `connect_timeout`).
         """
-        if interface is None:
-            interface = self.interface
-        if binary:
-            data = base64.decodebytes(data.encode() if isinstance(data, str) else data)
+        loop = get_or_create_event_loop()
+        if isinstance(data, str):
+            data = base64.b64decode(data.encode())
 
-        self.connect(device, interface=interface, **kwargs)
-        req = self._req_by_addr[device]
-
-        data = req.write_by_handle(handle, data)[0]
-
-        if binary:
-            data = base64.encodebytes(data.encode() if isinstance(data, str) else data).decode().strip()
-        if disconnect_on_recv:
-            self.disconnect(device)
-
-        return data
+        loop.run_until_complete(
+            self._write(device, data, service_uuid, interface, connect_timeout)
+        )
 
+    @override
     @action
-    def disconnect(self, device: str):
+    def status(self, *_, **__) -> Collection[Entity]:
         """
-        Disconnect from a connected device
-
-        :param device: Device address
+        Alias for :meth:`.scan`.
         """
-        req = self._req_by_addr.get(device)
-        if not req:
-            self.logger.info('Device {} not connected'.format(device))
+        return self.scan().output
 
-        req.disconnect()
-        self.logger.info('Device {} disconnected'.format(device))
+    @override
+    def transform_entities(self, entities: Collection[BLEDevice]) -> Collection[Device]:
+        return [
+            Device(
+                id=dev.address,
+                name=self._get_device_name(dev),
+            )
+            for dev in entities
+        ]
 
-    @action
-    def discover_primary(self, device: str, interface: str = None, **kwargs) -> BluetoothDiscoverPrimaryResponse:
-        """
-        Discover the primary services advertised by a LE bluetooth device
+    @override
+    async def listen(self):
+        device_addresses = set()
 
-        :param device: Device address to connect to
-        :param interface: Bluetooth adapter name to use (default configured if None)
-        :param kwargs: Extra arguments to be passed to :meth:`connect`
-        """
-        if interface is None:
-            interface = self.interface
+        while True:
+            entities = await self._scan()
+            new_device_addresses = {e.id for e in entities}
+            missing_device_addresses = device_addresses - new_device_addresses
+            missing_devices = [
+                dev
+                for addr, dev in self._devices.items()
+                if addr in missing_device_addresses
+            ]
 
-        self.connect(device, interface=interface, **kwargs)
-        req = self._req_by_addr[device]
-        services = req.discover_primary()
-        self.disconnect(device)
-        return BluetoothDiscoverPrimaryResponse(services=services)
+            for dev in missing_devices:
+                self._post_event(BluetoothDeviceLostEvent, dev)
+                self._devices.pop(dev.address, None)
 
-    @action
-    def discover_characteristics(self, device: str, interface: str = None, **kwargs) \
-            -> BluetoothDiscoverCharacteristicsResponse:
-        """
-        Discover the characteristics of a LE bluetooth device
-
-        :param device: Device address to connect to
-        :param interface: Bluetooth adapter name to use (default configured if None)
-        :param kwargs: Extra arguments to be passed to :meth:`connect`
-        """
-        if interface is None:
-            interface = self.interface
-
-        self.connect(device, interface=interface, **kwargs)
-        req = self._req_by_addr[device]
-        characteristics = req.discover_characteristics()
-        self.disconnect(device)
-        return BluetoothDiscoverCharacteristicsResponse(characteristics=characteristics)
+            device_addresses = new_device_addresses
 
 
 # vim:sw=4:ts=4:et:
diff --git a/platypush/plugins/bluetooth/ble/manifest.yaml b/platypush/plugins/bluetooth/ble/manifest.yaml
index 4f6c29bdd..554d609ab 100644
--- a/platypush/plugins/bluetooth/ble/manifest.yaml
+++ b/platypush/plugins/bluetooth/ble/manifest.yaml
@@ -2,7 +2,6 @@ manifest:
   events: {}
   install:
     pip:
-    - pybluez
-    - gattlib
+    - bleak
   package: platypush.plugins.bluetooth.ble
   type: plugin
diff --git a/platypush/plugins/switchbot/bluetooth/__init__.py b/platypush/plugins/switchbot/bluetooth/__init__.py
index d355a0248..353a59f23 100644
--- a/platypush/plugins/switchbot/bluetooth/__init__.py
+++ b/platypush/plugins/switchbot/bluetooth/__init__.py
@@ -1,19 +1,29 @@
 import enum
+from typing import Collection
 from uuid import UUID
-from threading import RLock
-from typing import Collection, Dict, Optional, Union
 
-from bleak import BleakClient, BleakScanner
 from bleak.backends.device import BLEDevice
+from typing_extensions import override
 
 from platypush.context import get_or_create_event_loop
-from platypush.entities import Entity, EnumSwitchEntityManager
+from platypush.entities import EnumSwitchEntityManager
 from platypush.entities.switches import EnumSwitch
-from platypush.plugins import AsyncRunnablePlugin, action
+from platypush.plugins import action
+from platypush.plugins.bluetooth.ble import BluetoothBlePlugin, UUIDType
+
+
+class Command(enum.Enum):
+    """
+    Supported commands.
+    """
+
+    PRESS = b'\x57\x01\x00'
+    ON = b'\x57\x01\x01'
+    OFF = b'\x57\x01\x02'
 
 
 # pylint: disable=too-many-ancestors
-class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
+class SwitchbotBluetoothPlugin(BluetoothBlePlugin, EnumSwitchEntityManager):
     """
     Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
     programmatically control switches over a Bluetooth interface.
@@ -29,97 +39,30 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
 
     """
 
-    # Bluetooth UUID prefixes exposed by SwitchBot devices
+    # Map of service names -> UUID prefixes exposed by SwitchBot devices
     _uuid_prefixes = {
         'tx': '002',
         'rx': '003',
         'service': 'd00',
     }
 
-    # Static list of Bluetooth UUIDs commonly exposed by SwitchBot devices.
+    # Static list of Bluetooth service UUIDs commonly exposed by SwitchBot
+    # devices.
     _uuids = {
         service: UUID(f'cba20{prefix}-224d-11e6-9fb8-0002a5d5c51b')
         for service, prefix in _uuid_prefixes.items()
     }
 
-    class Command(enum.Enum):
-        """
-        Supported commands.
-        """
-
-        PRESS = b'\x57\x01\x00'
-        ON = b'\x57\x01\x01'
-        OFF = b'\x57\x01\x02'
-
-    def __init__(
-        self,
-        interface: Optional[str] = None,
-        connect_timeout: Optional[float] = 5,
-        device_names: Optional[Dict[str, str]] = None,
-        **kwargs,
-    ):
-        """
-        :param interface: Name of the Bluetooth interface to use (default: first available).
-        :param connect_timeout: Timeout in seconds for the connection to the
-            Switchbot device. Default: 5 seconds
-        :param device_names: Bluetooth address -> device name mapping. If not
-            specified, the device's address will be used as a name as well.
-
-        Example:
-            .. code-block:: json
-
-            {
-                '00:11:22:33:44:55': 'My Switchbot',
-                '00:11:22:33:44:56': 'My Switchbot 2',
-                '00:11:22:33:44:57': 'My Switchbot 3'
-            }
-
-        """
-        super().__init__(**kwargs)
-
-        self._interface = interface
-        self._connect_timeout = connect_timeout if connect_timeout else 5
-        self._scan_lock = RLock()
-        self._devices: Dict[str, BLEDevice] = {}
-        self._device_name_by_addr = device_names or {}
-        self._device_addr_by_name = {
-            name: addr for addr, name in self._device_name_by_addr.items()
-        }
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, service_uuids=self._uuids.values(), **kwargs)
 
     async def _run(
-        self, device: str, command: Command, uuid: Union[UUID, str] = _uuids['tx']
+        self,
+        device: str,
+        command: Command,
+        service_uuid: UUIDType = _uuids['tx'],
     ):
-        """
-        Run a command on a Switchbot device.
-
-        :param device: Device name or address.
-        :param command: Command to run.
-        :param uuid: On which UUID the command should be sent. Default: the
-            Switchbot registered ``tx`` service.
-        """
-        dev = await self._get_device(device)
-        async with BleakClient(
-            dev.address, adapter=self._interface, timeout=self._connect_timeout
-        ) as client:
-            await client.write_gatt_char(str(uuid), command.value)
-
-    async def _get_device(self, device: str) -> BLEDevice:
-        """
-        Utility method to get a device by name or address.
-        """
-        addr = (
-            self._device_addr_by_name[device]
-            if device in self._device_addr_by_name
-            else device
-        )
-
-        if addr not in self._devices:
-            self.logger.info('Scanning for unknown device "%s"', device)
-            await self._scan()
-
-        dev = self._devices.get(addr)
-        assert dev is not None, f'Unknown device: "{device}"'
-        return dev
+        await self._write(device, command.value, service_uuid)
 
     @action
     def press(self, device: str):
@@ -129,7 +72,7 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
         :param device: Device name or address
         """
         loop = get_or_create_event_loop()
-        return loop.run_until_complete(self._run(device, self.Command.PRESS))
+        return loop.run_until_complete(self._run(device, Command.PRESS))
 
     @action
     def toggle(self, device, **_):
@@ -143,7 +86,7 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
         :param device: Device name or address
         """
         loop = get_or_create_event_loop()
-        return loop.run_until_complete(self._run(device, self.Command.ON))
+        return loop.run_until_complete(self._run(device, Command.ON))
 
     @action
     def off(self, device: str, **_):
@@ -153,11 +96,11 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
         :param device: Device name or address
         """
         loop = get_or_create_event_loop()
-        return loop.run_until_complete(self._run(device, self.Command.OFF))
+        return loop.run_until_complete(self._run(device, Command.OFF))
 
+    @override
     @action
-    # pylint: disable=arguments-differ
-    def set_value(self, device: str, data: str, *_, **__):
+    def set_value(self, device: str, *_, data: str, **__):
         """
         Entity-compatible ``set_value`` method to send a command to a device.
 
@@ -170,76 +113,29 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
 
         """
         if data == 'on':
-            return self.on(device)
+            self.on(device)
         if data == 'off':
-            return self.off(device)
+            self.off(device)
         if data == 'press':
-            return self.press(device)
+            self.press(device)
 
         self.logger.warning('Unknown command for SwitchBot "%s": "%s"', device, data)
-        return None
-
-    @action
-    def scan(self, duration: Optional[float] = None) -> Collection[Entity]:
-        """
-        Scan for available Switchbot devices nearby.
-
-        :param duration: Scan duration in seconds (default: same as the plugin's
-            `poll_interval` configuration parameter)
-        :return: The list of discovered Switchbot devices.
-        """
-        loop = get_or_create_event_loop()
-        return loop.run_until_complete(self._scan(duration))
-
-    @action
-    def status(self, *_, **__) -> Collection[Entity]:
-        """
-        Alias for :meth:`.scan`.
-        """
-        return self.scan().output
-
-    async def _scan(self, duration: Optional[float] = None) -> Collection[Entity]:
-        with self._scan_lock:
-            timeout = duration or self.poll_interval or 5
-            devices = await BleakScanner.discover(
-                adapter=self._interface, timeout=timeout
-            )
-
-            compatible_devices = [
-                d
-                for d in devices
-                if set(d.metadata.get('uuids', [])).intersection(
-                    map(str, self._uuids.values())
-                )
-            ]
-
-        new_devices = [
-            dev for dev in compatible_devices if dev.address not in self._devices
-        ]
-
-        self._devices.update({dev.address: dev for dev in compatible_devices})
-
-        entities = self.transform_entities(compatible_devices)
-        self.publish_entities(new_devices)
-        return entities
 
+    @override
     def transform_entities(
         self, entities: Collection[BLEDevice]
     ) -> Collection[EnumSwitch]:
+        devices = super().transform_entities(entities)
         return [
             EnumSwitch(
-                id=dev.address,
-                name=self._device_name_by_addr.get(dev.address, dev.name),
-                value='on',
+                id=dev.id,
+                name=dev.name,
+                value=None,
                 values=['on', 'off', 'press'],
                 is_write_only=True,
             )
-            for dev in entities
+            for dev in devices
         ]
 
-    async def listen(self):
-        while True:
-            await self._scan()
-
 
 # vim:sw=4:ts=4:et: