Rewriting `bluetooth.ble` plugin to use `bleak` instead of `gattlib`.

This commit is contained in:
Fabio Manganiello 2023-02-10 17:40:20 +01:00
parent f30e077a5a
commit b0cc80ceb0
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
17 changed files with 538 additions and 505 deletions

View File

@ -13,7 +13,6 @@ Backends
platypush/backend/bluetooth.fileserver.rst platypush/backend/bluetooth.fileserver.rst
platypush/backend/bluetooth.pushserver.rst platypush/backend/bluetooth.pushserver.rst
platypush/backend/bluetooth.scanner.rst platypush/backend/bluetooth.scanner.rst
platypush/backend/bluetooth.scanner.ble.rst
platypush/backend/button.flic.rst platypush/backend/button.flic.rst
platypush/backend/camera.pi.rst platypush/backend/camera.pi.rst
platypush/backend/chat.telegram.rst platypush/backend/chat.telegram.rst
@ -74,6 +73,5 @@ Backends
platypush/backend/weather.openweathermap.rst platypush/backend/weather.openweathermap.rst
platypush/backend/websocket.rst platypush/backend/websocket.rst
platypush/backend/wiimote.rst platypush/backend/wiimote.rst
platypush/backend/zigbee.mqtt.rst
platypush/backend/zwave.rst platypush/backend/zwave.rst
platypush/backend/zwave.mqtt.rst platypush/backend/zwave.mqtt.rst

View File

@ -11,6 +11,7 @@ Events
platypush/events/application.rst platypush/events/application.rst
platypush/events/assistant.rst platypush/events/assistant.rst
platypush/events/bluetooth.rst platypush/events/bluetooth.rst
platypush/events/bluetooth.ble.rst
platypush/events/button.flic.rst platypush/events/button.flic.rst
platypush/events/camera.rst platypush/events/camera.rst
platypush/events/chat.slack.rst platypush/events/chat.slack.rst

View File

@ -1,5 +0,0 @@
``bluetooth.scanner.ble``
===========================================
.. automodule:: platypush.backend.bluetooth.scanner.ble
:members:

View File

@ -1,5 +0,0 @@
``zigbee.mqtt``
=================================
.. automodule:: platypush.backend.zigbee.mqtt
:members:

View File

@ -0,0 +1,5 @@
``bluetooth.ble``
=================
.. automodule:: platypush.message.event.bluetooth.ble
:members:

View File

@ -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:

View File

@ -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

View File

@ -3,7 +3,11 @@ from typing import Collection, Optional
from ._base import Entity, get_entities_registry, init_entities_db from ._base import Entity, get_entities_registry, init_entities_db
from ._engine import EntitiesEngine 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.lights import LightEntityManager
from ._managers.sensors import SensorEntityManager from ._managers.sensors import SensorEntityManager
from ._managers.switches import ( from ._managers.switches import (
@ -50,6 +54,7 @@ __all__ = (
'DimmerEntityManager', 'DimmerEntityManager',
'EntitiesEngine', 'EntitiesEngine',
'Entity', 'Entity',
'EntityManager',
'EnumSwitchEntityManager', 'EnumSwitchEntityManager',
'LightEntityManager', 'LightEntityManager',
'SensorEntityManager', 'SensorEntityManager',

View File

@ -109,11 +109,7 @@ def register_entity_manager(cls: Type[EntityManager]):
Associates a plugin as a manager for a certain entity type. Associates a plugin as a manager for a certain entity type.
You usually don't have to call this method directly. You usually don't have to call this method directly.
""" """
entity_managers = [ entity_managers = [c for c in inspect.getmro(cls) if issubclass(c, EntityManager)]
c
for c in inspect.getmro(cls)
if issubclass(c, EntityManager) and c not in {cls, EntityManager}
]
plugin_name = get_plugin_name_by_class(cls) or '' plugin_name = get_plugin_name_by_class(cls) or ''
redis = get_redis() redis = get_redis()

View File

@ -35,7 +35,7 @@ class MultiLevelSwitchEntityManager(EntityManager, ABC):
@abstractmethod @abstractmethod
def set_value( # pylint: disable=redefined-builtin def set_value( # pylint: disable=redefined-builtin
self, *_, property=None, data=None, **__ self, device=None, property=None, *, data=None, **__
): ):
"""Set a value""" """Set a value"""
raise NotImplementedError() raise NotImplementedError()

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -206,9 +206,13 @@ class AsyncRunnablePlugin(RunnablePlugin, ABC):
self._loop = asyncio.new_event_loop() self._loop = asyncio.new_event_loop()
if self._should_start_runner: if self._should_start_runner:
self._run_listener() while not self.should_stop():
try:
self.wait_stop() self._run_listener()
finally:
self.wait_stop(self.poll_interval)
else:
self.wait_stop()
def stop(self): def stop(self):
if self._loop and self._loop.is_running(): if self._loop and self._loop.is_running():

View File

@ -1,275 +1,352 @@
import base64 import base64
import os from contextlib import asynccontextmanager
import subprocess from threading import RLock
import sys from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union
import time from uuid import UUID
from typing import Optional, Dict
from platypush.plugins import action from bleak import BleakClient, BleakScanner
from platypush.plugins.sensor import SensorPlugin from bleak.backends.device import BLEDevice
from platypush.message.response.bluetooth import BluetoothScanResponse, BluetoothDiscoverPrimaryResponse, \ from typing_extensions import override
BluetoothDiscoverCharacteristicsResponse
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: Requires:
* **pybluez** (``pip install pybluez``) * **bleak** (``pip install bleak``)
* **gattlib** (``pip install gattlib``)
Note that the support for bluetooth low-energy devices on Linux requires: TODO: Write supported events.
* 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
""" """
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) super().__init__(**kwargs)
self.interface = interface
self._req_by_addr = {}
@staticmethod self._interface = interface
def _get_python_interpreter() -> str: self._connect_timeout = connect_timeout
exe = sys.executable 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): async def _get_device(self, device: str) -> BLEDevice:
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:
""" """
Scan for nearby bluetooth low-energy devices Utility method to get a device by name or address.
:param interface: Bluetooth adapter name to use (default configured if None)
:param duration: Scan duration in seconds
""" """
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: if addr not in self._devices:
interface = self.interface self.logger.info('Scanning for unknown device "%s"', device)
await self._scan()
self._check_ble_support() dev = self._devices.get(addr)
svc = DiscoveryService(interface) assert dev is not None, f'Unknown device: "{device}"'
devices = svc.discover(duration) return dev
return BluetoothScanResponse(devices)
@action def _get_device_name(self, device: BLEDevice) -> str:
def get_measurement(self, interface: Optional[str] = None, duration: Optional[int] = 10, *args, **kwargs) \ return (
-> Dict[str, dict]: self._device_name_by_addr.get(device.address)
""" or device.name
Wrapper for ``scan`` that returns bluetooth devices in a format usable by sensor backends. or device.address
)
:param interface: Bluetooth adapter name to use (default configured if None) def _post_event(
:param duration: Scan duration in seconds self, event_type: Type[BluetoothEvent], device: BLEDevice, **kwargs
:return: Device address -> info map. ):
""" props = device.details.get('props', {})
devices = self.scan(interface=interface, duration=duration).output get_bus().post(
return {device['addr']: device for device in devices} 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 def _on_device_event(self, device: BLEDevice, _):
@action event_types: List[Type[BluetoothEvent]] = []
def connect(self, device: str, interface: str = None, wait: bool = True, channel_type: str = 'public', existing_device = self._devices.get(device.address)
security_level: str = 'low', psm: int = 0, mtu: int = 0, timeout: float = 10.0):
"""
Connect to a bluetooth LE device
:param device: Device address to connect to if existing_device:
:param interface: Bluetooth adapter name to use (default configured if None) old_props = existing_device.details.get('props', {})
:param wait: If True then wait for the connection to be established before returning (no timeout) new_props = device.details.get('props', {})
: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
req = self._req_by_addr.get(device) if old_props.get('Paired') != new_props.get('Paired'):
if req: event_types.append(
if req.is_connected(): BluetoothDevicePairedEvent
self.logger.info('Device {} is already connected'.format(device)) if new_props.get('Paired')
return 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: if old_props.get('Blocked') != new_props.get('Blocked'):
interface = self.interface event_types.append(
if interface: BluetoothDeviceBlockedEvent
req = GATTRequester(device, False, interface) 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: else:
req = GATTRequester(device, False) event_types.append(BluetoothDeviceFoundEvent)
self.logger.info('Connecting to {}'.format(device)) self._devices[device.address] = device
connect_start_time = time.time()
req.connect(wait, channel_type, security_level, psm, mtu)
if not wait: if event_types:
while not req.is_connected(): for event_type in event_types:
if time.time() - connect_start_time > timeout: self._post_event(event_type, device)
raise TimeoutError('Connection to {} timed out'.format(device)) self.publish_entities([device])
time.sleep(0.1)
self.logger.info('Connected to {}'.format(device)) @asynccontextmanager
self._req_by_addr[device] = req 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 async def _read(
def read(self, device: str, interface: str = None, uuid: str = None, handle: int = None, self,
binary: bool = False, disconnect_on_recv: bool = True, **kwargs) -> str: device: str,
""" service_uuid: UUIDType,
Read a message from a device interface: Optional[str] = None,
connect_timeout: Optional[float] = None,
:param device: Device address to connect to ) -> bytearray:
:param interface: Bluetooth adapter name to use (default configured if None) async with self._connect(device, interface, connect_timeout) as client:
:param uuid: Service UUID. Either the UUID or the device handle must be specified data = await client.read_gatt_char(service_uuid)
: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)
return data 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 @action
def write(self, device: str, data, handle: int = None, interface: str = None, binary: bool = False, def scan(
disconnect_on_recv: bool = True, **kwargs) -> str: 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 Writes data to a device
:param device: Device address to connect to :param device: Name or address of the device to read from.
:param data: Data to be written (str or bytes) :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 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 connect_timeout: Connection timeout in seconds (default: same as the
:param binary: Set to true if data is a base64-encoded binary string configured `connect_timeout`).
: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: loop = get_or_create_event_loop()
interface = self.interface if isinstance(data, str):
if binary: data = base64.b64decode(data.encode())
data = base64.decodebytes(data.encode() if isinstance(data, str) else data)
self.connect(device, interface=interface, **kwargs) loop.run_until_complete(
req = self._req_by_addr[device] self._write(device, data, service_uuid, interface, connect_timeout)
)
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
@override
@action @action
def disconnect(self, device: str): def status(self, *_, **__) -> Collection[Entity]:
""" """
Disconnect from a connected device Alias for :meth:`.scan`.
:param device: Device address
""" """
req = self._req_by_addr.get(device) return self.scan().output
if not req:
self.logger.info('Device {} not connected'.format(device))
req.disconnect() @override
self.logger.info('Device {} disconnected'.format(device)) 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 @override
def discover_primary(self, device: str, interface: str = None, **kwargs) -> BluetoothDiscoverPrimaryResponse: async def listen(self):
""" device_addresses = set()
Discover the primary services advertised by a LE bluetooth device
:param device: Device address to connect to while True:
:param interface: Bluetooth adapter name to use (default configured if None) entities = await self._scan()
:param kwargs: Extra arguments to be passed to :meth:`connect` new_device_addresses = {e.id for e in entities}
""" missing_device_addresses = device_addresses - new_device_addresses
if interface is None: missing_devices = [
interface = self.interface dev
for addr, dev in self._devices.items()
if addr in missing_device_addresses
]
self.connect(device, interface=interface, **kwargs) for dev in missing_devices:
req = self._req_by_addr[device] self._post_event(BluetoothDeviceLostEvent, dev)
services = req.discover_primary() self._devices.pop(dev.address, None)
self.disconnect(device)
return BluetoothDiscoverPrimaryResponse(services=services)
@action device_addresses = new_device_addresses
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)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -2,7 +2,6 @@ manifest:
events: {} events: {}
install: install:
pip: pip:
- pybluez - bleak
- gattlib
package: platypush.plugins.bluetooth.ble package: platypush.plugins.bluetooth.ble
type: plugin type: plugin

View File

@ -1,19 +1,29 @@
import enum import enum
from typing import Collection
from uuid import UUID 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 bleak.backends.device import BLEDevice
from typing_extensions import override
from platypush.context import get_or_create_event_loop 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.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 # 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 Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
programmatically control switches over a Bluetooth interface. 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 = { _uuid_prefixes = {
'tx': '002', 'tx': '002',
'rx': '003', 'rx': '003',
'service': 'd00', 'service': 'd00',
} }
# Static list of Bluetooth UUIDs commonly exposed by SwitchBot devices. # Static list of Bluetooth service UUIDs commonly exposed by SwitchBot
# devices.
_uuids = { _uuids = {
service: UUID(f'cba20{prefix}-224d-11e6-9fb8-0002a5d5c51b') service: UUID(f'cba20{prefix}-224d-11e6-9fb8-0002a5d5c51b')
for service, prefix in _uuid_prefixes.items() for service, prefix in _uuid_prefixes.items()
} }
class Command(enum.Enum): def __init__(self, *args, **kwargs):
""" super().__init__(*args, service_uuids=self._uuids.values(), **kwargs)
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()
}
async def _run( async def _run(
self, device: str, command: Command, uuid: Union[UUID, str] = _uuids['tx'] self,
device: str,
command: Command,
service_uuid: UUIDType = _uuids['tx'],
): ):
""" await self._write(device, command.value, service_uuid)
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
@action @action
def press(self, device: str): def press(self, device: str):
@ -129,7 +72,7 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
:param device: Device name or address :param device: Device name or address
""" """
loop = get_or_create_event_loop() 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 @action
def toggle(self, device, **_): def toggle(self, device, **_):
@ -143,7 +86,7 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
:param device: Device name or address :param device: Device name or address
""" """
loop = get_or_create_event_loop() 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 @action
def off(self, device: str, **_): def off(self, device: str, **_):
@ -153,11 +96,11 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
:param device: Device name or address :param device: Device name or address
""" """
loop = get_or_create_event_loop() 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 @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. Entity-compatible ``set_value`` method to send a command to a device.
@ -170,76 +113,29 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
""" """
if data == 'on': if data == 'on':
return self.on(device) self.on(device)
if data == 'off': if data == 'off':
return self.off(device) self.off(device)
if data == 'press': if data == 'press':
return self.press(device) self.press(device)
self.logger.warning('Unknown command for SwitchBot "%s": "%s"', device, data) 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( def transform_entities(
self, entities: Collection[BLEDevice] self, entities: Collection[BLEDevice]
) -> Collection[EnumSwitch]: ) -> Collection[EnumSwitch]:
devices = super().transform_entities(entities)
return [ return [
EnumSwitch( EnumSwitch(
id=dev.address, id=dev.id,
name=self._device_name_by_addr.get(dev.address, dev.name), name=dev.name,
value='on', value=None,
values=['on', 'off', 'press'], values=['on', 'off', 'press'],
is_write_only=True, 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: # vim:sw=4:ts=4:et: