diff --git a/docs/source/backends.rst b/docs/source/backends.rst index 74b225f6..995fd8dd 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 8c4d1306..28df635c 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 ed8f59c9..00000000 --- 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 dd524f14..00000000 --- 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 00000000..c2573a0c --- /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 30e1d625..00000000 --- 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 65be3ca9..00000000 --- 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 5c9e7a70..d51b8539 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 e08cec7f..a70ca83c 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 cd78d13d..eef80f0f 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 b70f6848..00000000 --- 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 00000000..1e55f4ce --- /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 00000000..5289e6b9 --- /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 a3fb325c..a26b13f9 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 623606ac..ebb65ad7 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 4f6c29bd..554d609a 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 d355a024..353a59f2 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: