diff --git a/docs/source/conf.py b/docs/source/conf.py index c7e83917..bc25e1b3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -297,6 +297,7 @@ autodoc_mock_imports = [ 'aiofiles.os', 'async_lru', 'bleak', + 'bluetooth_numbers', ] sys.path.insert(0, os.path.abspath('../..')) diff --git a/platypush/entities/bluetooth.py b/platypush/entities/bluetooth.py index 5dcd2df2..00125b20 100644 --- a/platypush/entities/bluetooth.py +++ b/platypush/entities/bluetooth.py @@ -1,4 +1,10 @@ -from sqlalchemy import Column, Integer, Boolean, ForeignKey +from sqlalchemy import ( + Boolean, + Column, + ForeignKey, + Integer, + JSON, +) from platypush.common.db import Base @@ -17,7 +23,46 @@ if 'bluetooth_device' not in Base.metadata: id = Column( Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True ) + connected = Column(Boolean, default=False) + """ Whether the device is connected. """ + + paired = Column(Boolean, default=False) + """ Whether the device is paired. """ + + trusted = Column(Boolean, default=False) + """ Whether the device is trusted. """ + + blocked = Column(Boolean, default=False) + """ Whether the device is blocked. """ + + rssi = Column(Integer, default=None) + """ Received Signal Strength Indicator. """ + + tx_power = Column(Integer, default=None) + """ Reported transmission power. """ + + manufacturers = Column(JSON) + """ Registered manufacturers for the device, as an ID -> Name map. """ + + uuids = Column(JSON) + """ + Service/characteristic UUIDs exposed by the device, as a + UUID -> Name map. + """ + + manufacturer_data = Column(JSON) + """ + Latest manufacturer data published by the device, as a + ``manufacturer_id -> data`` map, where ``data`` is a hexadecimal + string. + """ + + service_data = Column(JSON) + """ + Latest service data published by the device, as a ``service_uuid -> + data`` map, where ``data`` is a hexadecimal string. + """ __mapper_args__ = { 'polymorphic_identity': __tablename__, diff --git a/platypush/message/event/bluetooth/__init__.py b/platypush/message/event/bluetooth/__init__.py index 1e55f4ce..df9439fb 100644 --- a/platypush/message/event/bluetooth/__init__.py +++ b/platypush/message/event/bluetooth/__init__.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Dict, Optional from platypush.message.event import Event @@ -8,44 +8,162 @@ 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): +class BluetoothScanPausedEvent(BluetoothEvent): """ - Base class for Bluetooth events that include a communication port. + Event triggered when the Bluetooth scan is paused. + """ + + def __init__(self, *args, duration: Optional[float] = None, **kwargs): + super().__init__(*args, duration=duration, **kwargs) + + +class BluetoothScanResumedEvent(BluetoothEvent): + """ + Event triggered when the Bluetooth scan is resumed. + """ + + def __init__(self, *args, duration: Optional[float] = None, **kwargs): + super().__init__(*args, duration=duration, **kwargs) + + +class BluetoothWithPortEvent(Event): + """ + Base class for Bluetooth events with an associated port. """ def __init__(self, *args, port: Optional[str] = None, **kwargs): + """ + :param port: The communication port of the device. + """ super().__init__(*args, port=port, **kwargs) -class BluetoothDeviceFoundEvent(BluetoothEvent): +class BluetoothDeviceEvent(BluetoothWithPortEvent): """ - Event triggered when a Bluetooth device is found during a scan. + Base class for Bluetooth device events. + """ + + def __init__( + self, + *args, + address: str, + connected: bool, + paired: bool, + trusted: bool, + blocked: bool, + name: Optional[str] = None, + uuids: Optional[Dict[str, str]] = None, + rssi: Optional[int] = None, + tx_power: Optional[int] = None, + manufacturers: Optional[Dict[int, str]] = None, + manufacturer_data: Optional[Dict[int, str]] = None, + service_data: Optional[Dict[str, 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 uuids: The UUIDs of the services exposed by the device. + :param rssi: Received Signal Strength Indicator. + :param tx_power: Transmission power. + :param manufacturers: The manufacturers published by the device, as a + ``manufacturer_id -> registered_name`` map. + :param manufacturer_data: The manufacturer data published by the + device, as a ``manufacturer_id -> data`` map, where ``data`` is a + hexadecimal string. + :param service_data: The service data published by the device, as a + ``service_uuid -> data`` map, where ``data`` is a hexadecimal string. + """ + super().__init__( + *args, + address=address, + name=name, + connected=connected, + paired=paired, + blocked=blocked, + trusted=trusted, + uuids=uuids or {}, + rssi=rssi, + tx_power=tx_power, + manufacturers=manufacturers or {}, + manufacturer_data=manufacturer_data or {}, + service_data=service_data or {}, + **kwargs + ) + + +class BluetoothDeviceFoundEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is discovered during a scan. """ -class BluetoothDeviceLostEvent(BluetoothEvent): +class BluetoothDeviceLostEvent(BluetoothDeviceEvent): """ - Event triggered when a Bluetooth device previously scanned is lost. + Event triggered when a previously discovered Bluetooth device is lost. """ -class BluetoothDeviceConnectedEvent(BluetoothWithPortEvent): +class BluetoothDeviceConnectedEvent(BluetoothDeviceEvent): """ Event triggered when a Bluetooth device is connected. """ -class BluetoothDeviceDisconnectedEvent(BluetoothWithPortEvent): +class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent): """ Event triggered when a Bluetooth device is disconnected. """ -class BluetoothConnectionRejectedEvent(BluetoothWithPortEvent): +class BluetoothDevicePairedEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is paired. + """ + + +class BluetoothDeviceUnpairedEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is unpaired. + """ + + +class BluetoothDeviceBlockedEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is blocked. + """ + + +class BluetoothDeviceUnblockedEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is unblocked. + """ + + +class BluetoothDeviceTrustedEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is trusted. + """ + + +class BluetoothDeviceSignalUpdateEvent(BluetoothDeviceEvent): + """ + Event triggered when the RSSI/TX power of a Bluetooth device is updated. + """ + + +class BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent): + """ + Event triggered when a Bluetooth device is untrusted. + """ + + +class BluetoothConnectionRejectedEvent(BluetoothDeviceEvent): """ Event triggered when a Bluetooth connection is rejected. """ diff --git a/platypush/message/event/bluetooth/ble.py b/platypush/message/event/bluetooth/ble.py deleted file mode 100644 index 3d8b4e29..00000000 --- a/platypush/message/event/bluetooth/ble.py +++ /dev/null @@ -1,130 +0,0 @@ -from typing import Collection, Optional - -from platypush.message.event import Event - - -class BluetoothEvent(Event): - """ - Base class for Bluetooth events. - """ - - -class BluetoothScanPausedEvent(BluetoothEvent): - """ - Event triggered when the Bluetooth scan is paused. - """ - - def __init__(self, *args, duration: Optional[float] = None, **kwargs): - super().__init__(*args, duration=duration, **kwargs) - - -class BluetoothScanResumedEvent(BluetoothEvent): - """ - Event triggered when the Bluetooth scan is resumed. - """ - - def __init__(self, *args, duration: Optional[float] = None, **kwargs): - super().__init__(*args, duration=duration, **kwargs) - - -class BluetoothDeviceEvent(BluetoothEvent): - """ - Base class for Bluetooth device events. - """ - - def __init__( - self, - *args, - address: str, - connected: bool, - paired: bool, - trusted: bool, - blocked: bool, - name: Optional[str] = None, - characteristics: 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 characteristics: The UUIDs of the characteristics exposed by the - device. - """ - super().__init__( - *args, - address=address, - name=name, - connected=connected, - paired=paired, - blocked=blocked, - trusted=trusted, - characteristics=characteristics or [], - **kwargs - ) - - -class BluetoothDeviceFoundEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is discovered during a scan. - """ - - -class BluetoothDeviceLostEvent(BluetoothDeviceEvent): - """ - Event triggered when a previously discovered Bluetooth device is lost. - """ - - -class BluetoothDeviceConnectedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is connected. - """ - - -class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is disconnected. - """ - - -class BluetoothDevicePairedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is paired. - """ - - -class BluetoothDeviceUnpairedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is unpaired. - """ - - -class BluetoothDeviceBlockedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is blocked. - """ - - -class BluetoothDeviceUnblockedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is unblocked. - """ - - -class BluetoothDeviceTrustedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is trusted. - """ - - -class BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent): - """ - Event triggered when a Bluetooth device is untrusted. - """ - - -# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/bluetooth/ble/__init__.py b/platypush/plugins/bluetooth/ble/__init__.py index 601240d0..c7a64e0c 100644 --- a/platypush/plugins/bluetooth/ble/__init__.py +++ b/platypush/plugins/bluetooth/ble/__init__.py @@ -2,23 +2,38 @@ import base64 from asyncio import Event, ensure_future from contextlib import asynccontextmanager from threading import RLock, Timer -from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union +from time import time +from typing import ( + Any, + AsyncGenerator, + Collection, + Final, + List, + Optional, + Dict, + Type, + Union, +) from uuid import UUID from bleak import BleakClient, BleakScanner from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData +from bleak.uuids import uuidstr_to_str +from bluetooth_numbers import company 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.bluetooth import BluetoothDevice -from platypush.message.event.bluetooth.ble import ( +from platypush.message.event.bluetooth import ( BluetoothDeviceBlockedEvent, BluetoothDeviceConnectedEvent, BluetoothDeviceDisconnectedEvent, BluetoothDeviceFoundEvent, BluetoothDeviceLostEvent, BluetoothDevicePairedEvent, + BluetoothDeviceSignalUpdateEvent, BluetoothDeviceTrustedEvent, BluetoothDeviceUnblockedEvent, BluetoothDeviceUnpairedEvent, @@ -42,20 +57,40 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): Requires: * **bleak** (``pip install bleak``) + * **bluetooth-numbers** (``pip install bluetooth-numbers``) - TODO: Write supported events. + Triggers: + + * :class:`platypush.message.event.bluetooth.BluetoothDeviceBlockedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDevicePairedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceUnpairedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent` + * :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent` """ - # Default connection timeout (in seconds) - _default_connect_timeout = 5 + _default_connect_timeout: Final[int] = 5 + """ Default connection timeout (in seconds) """ + + _rssi_update_interval: Final[int] = 30 + """ + How long we should wait before triggering an update event upon a new + RSSI update, in seconds. + """ def __init__( self, interface: Optional[str] = None, connect_timeout: float = _default_connect_timeout, device_names: Optional[Dict[str, str]] = None, - characteristics: Optional[Collection[UUIDType]] = None, + uuids: Optional[Collection[UUIDType]] = None, scan_paused_on_start: bool = False, **kwargs, ): @@ -64,8 +99,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): on Linux). Default: first available interface. :param connect_timeout: Timeout in seconds for the connection to a Bluetooth device. Default: 5 seconds. - :param characteristics: List of service/characteristic UUIDs to - discover. Default: all. + :param uuids: List of service/characteristic 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: @@ -85,14 +120,15 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): """ super().__init__(**kwargs) - self._interface = interface - self._connect_timeout = connect_timeout - self._characteristics = characteristics + self._interface: Optional[str] = interface + self._connect_timeout: float = connect_timeout + self._uuids: Collection[Union[str, UUID]] = uuids or [] self._scan_lock = RLock() self._scan_enabled = Event() self._scan_controller_timer: Optional[Timer] = None self._connections: Dict[str, BleakClient] = {} self._devices: Dict[str, BLEDevice] = {} + self._device_last_updated_at: Dict[str, float] = {} 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() @@ -129,21 +165,54 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): def _post_event( self, event_type: Type[BluetoothDeviceEvent], 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), - characteristics=device.metadata.get('uuids', []), - **kwargs, + address=device.address, **self._parse_device_args(device), **kwargs ) ) - def _on_device_event(self, device: BLEDevice, _): + def _parse_device_args(self, device: BLEDevice) -> Dict[str, Any]: + props = device.details.get('props', {}) + return { + '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), + 'rssi': device.rssi, + 'tx_power': props.get('TxPower'), + 'uuids': { + uuid: uuidstr_to_str(uuid) for uuid in device.metadata.get('uuids', []) + }, + 'manufacturers': { + manufacturer_id: company.get(manufacturer_id, 'Unknown') + for manufacturer_id in sorted( + device.metadata.get('manufacturer_data', {}).keys() + ) + }, + 'manufacturer_data': self._parse_manufacturer_data(device), + 'service_data': self._parse_service_data(device), + } + + @staticmethod + def _parse_manufacturer_data(device: BLEDevice) -> Dict[int, str]: + return { + manufacturer_id: ':'.join([f'{x:02x}' for x in value]) + for manufacturer_id, value in device.metadata.get( + 'manufacturer_data', {} + ).items() + } + + @staticmethod + def _parse_service_data(device: BLEDevice) -> Dict[str, str]: + return { + service_uuid: ':'.join([f'{x:02x}' for x in value]) + for service_uuid, value in device.details.get('props', {}) + .get('ServiceData', {}) + .items() + } + + def _on_device_event(self, device: BLEDevice, _: AdvertisementData): event_types: List[Type[BluetoothDeviceEvent]] = [] existing_device = self._devices.get(device.address) @@ -178,6 +247,15 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): if new_props.get('Trusted') else BluetoothDeviceUntrustedEvent ) + + if ( + time() - self._device_last_updated_at.get(device.address, 0) + ) >= self._rssi_update_interval and ( + existing_device.rssi != device.rssi + or old_props.get('TxPower') != new_props.get('TxPower') + ): + event_types.append(BluetoothDeviceSignalUpdateEvent) + else: event_types.append(BluetoothDeviceFoundEvent) @@ -190,6 +268,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): for event_type in event_types: self._post_event(event_type, device) self.publish_entities([device]) + self._device_last_updated_at[device.address] = time() @asynccontextmanager async def _connect( @@ -234,7 +313,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): async def _scan( self, duration: Optional[float] = None, - characteristics: Optional[Collection[UUIDType]] = None, + uuids: Optional[Collection[UUIDType]] = None, publish_entities: bool = False, ) -> Collection[Entity]: with self._scan_lock: @@ -242,14 +321,10 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): devices = await BleakScanner.discover( adapter=self._interface, timeout=timeout, - service_uuids=list( - map(str, characteristics or self._characteristics or []) - ), + service_uuids=list(map(str, uuids or self._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}) return ( self.publish_entities(devices) @@ -307,7 +382,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): def scan( self, duration: Optional[float] = None, - characteristics: Optional[Collection[UUIDType]] = None, + uuids: Optional[Collection[UUIDType]] = None, ): """ Scan for Bluetooth devices nearby and return the results as a list of @@ -315,11 +390,11 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): :param duration: Scan duration in seconds (default: same as the plugin's `poll_interval` configuration parameter) - :param characteristics: List of characteristic UUIDs to discover. Default: all. + :param uuids: List of characteristic UUIDs to discover. Default: all. """ loop = get_or_create_event_loop() return loop.run_until_complete( - self._scan(duration, characteristics, publish_entities=True) + self._scan(duration, uuids, publish_entities=True) ) @action @@ -388,7 +463,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager): return [ BluetoothDevice( id=dev.address, - name=self._get_device_name(dev), + **self._parse_device_args(dev), ) for dev in entities ] diff --git a/platypush/plugins/bluetooth/ble/manifest.yaml b/platypush/plugins/bluetooth/ble/manifest.yaml index 554d609a..4e65f46b 100644 --- a/platypush/plugins/bluetooth/ble/manifest.yaml +++ b/platypush/plugins/bluetooth/ble/manifest.yaml @@ -1,7 +1,20 @@ manifest: - events: {} + events: + platypush.message.event.bluetooth.BluetoothDeviceBlockedEvent: + platypush.message.event.bluetooth.BluetoothDeviceConnectedEvent: + platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent: + platypush.message.event.bluetooth.BluetoothDeviceFoundEvent: + platypush.message.event.bluetooth.BluetoothDeviceLostEvent: + platypush.message.event.bluetooth.BluetoothDevicePairedEvent: + platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent: + platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent: + platypush.message.event.bluetooth.BluetoothDeviceUnpairedEvent: + platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent: + platypush.message.event.bluetooth.BluetoothScanPausedEvent: + platypush.message.event.bluetooth.BluetoothScanResumedEvent: install: pip: - bleak + - bluetooth-numbers package: platypush.plugins.bluetooth.ble type: plugin diff --git a/setup.py b/setup.py index e66dbb46..99f7c7a5 100755 --- a/setup.py +++ b/setup.py @@ -173,9 +173,11 @@ setup( ], # Support for Alexa/Echo plugin 'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'], - # Support for bluetooth devices + # Support for Bluetooth devices 'bluetooth': [ 'bleak', + 'bluetooth-numbers', + 'pybluez', 'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master', ], # Support for TP-Link devices