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

View File

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

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 ._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',

View File

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

View File

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

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()
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():

View File

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

View File

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

View File

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