forked from platypush/platypush
Added stand-alone connect
and disconnect
actions to bluetooth
.
This commit is contained in:
parent
70d1bb893c
commit
15fadb93bb
1 changed files with 122 additions and 18 deletions
|
@ -1,7 +1,9 @@
|
||||||
import base64
|
import base64
|
||||||
from asyncio import Event, Lock, ensure_future
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from threading import RLock, Timer
|
from threading import RLock, Timer
|
||||||
|
import threading
|
||||||
from time import time
|
from time import time
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
@ -48,6 +50,19 @@ from ._mappers import device_to_entity, parse_device_args
|
||||||
UUIDType = Union[str, UUID]
|
UUIDType = Union[str, UUID]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _BluetoothConnection:
|
||||||
|
"""
|
||||||
|
A class to store information and context about a Bluetooth connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client: BleakClient
|
||||||
|
device: BLEDevice
|
||||||
|
loop: asyncio.AbstractEventLoop
|
||||||
|
close_event: Optional[asyncio.Event] = None
|
||||||
|
thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
|
||||||
class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
"""
|
"""
|
||||||
Plugin to interact with BLE (Bluetooth Low-Energy) devices.
|
Plugin to interact with BLE (Bluetooth Low-Energy) devices.
|
||||||
|
@ -75,7 +90,6 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDeviceDisconnectedEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDeviceFoundEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDeviceLostEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceNewDataEvent`
|
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDevicePairedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDevicePairedEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDeviceTrustedEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUnblockedEvent`
|
||||||
|
@ -83,10 +97,11 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothDeviceUntrustedEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothScanPausedEvent`
|
||||||
* :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent`
|
* :class:`platypush.message.event.bluetooth.BluetoothScanResumedEvent`
|
||||||
|
* :class:`platypush.message.event.entities.EntityUpdateEvent`
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_default_connect_timeout: Final[int] = 5
|
_default_connect_timeout: Final[int] = 10
|
||||||
""" Default connection timeout (in seconds) """
|
""" Default connection timeout (in seconds) """
|
||||||
|
|
||||||
_rssi_update_interval: Final[int] = 30
|
_rssi_update_interval: Final[int] = 30
|
||||||
|
@ -108,7 +123,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
|
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
|
||||||
on Linux). Default: first available interface.
|
on Linux). Default: first available interface.
|
||||||
:param connect_timeout: Timeout in seconds for the connection to a
|
:param connect_timeout: Timeout in seconds for the connection to a
|
||||||
Bluetooth device. Default: 5 seconds.
|
Bluetooth device. Default: 10 seconds.
|
||||||
:param uuids: List of service/characteristic UUIDs to discover.
|
:param uuids: List of service/characteristic UUIDs to discover.
|
||||||
Default: all.
|
Default: all.
|
||||||
:param device_names: Bluetooth address -> device name mapping. If not
|
:param device_names: Bluetooth address -> device name mapping. If not
|
||||||
|
@ -134,10 +149,10 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
self._connect_timeout: float = connect_timeout
|
self._connect_timeout: float = connect_timeout
|
||||||
self._uuids: Collection[Union[str, UUID]] = uuids or []
|
self._uuids: Collection[Union[str, UUID]] = uuids or []
|
||||||
self._scan_lock = RLock()
|
self._scan_lock = RLock()
|
||||||
self._scan_enabled = Event()
|
self._scan_enabled = asyncio.Event()
|
||||||
self._scan_controller_timer: Optional[Timer] = None
|
self._scan_controller_timer: Optional[Timer] = None
|
||||||
self._connections: Dict[str, BleakClient] = {}
|
self._connections: Dict[str, _BluetoothConnection] = {}
|
||||||
self._connection_locks: Dict[str, Lock] = {}
|
self._connection_locks: Dict[str, asyncio.Lock] = {}
|
||||||
self._devices: Dict[str, BLEDevice] = {}
|
self._devices: Dict[str, BLEDevice] = {}
|
||||||
self._entities: Dict[str, BluetoothDevice] = {}
|
self._entities: Dict[str, BluetoothDevice] = {}
|
||||||
self._device_last_updated_at: Dict[str, float] = {}
|
self._device_last_updated_at: Dict[str, float] = {}
|
||||||
|
@ -170,6 +185,13 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
def _post_event(
|
def _post_event(
|
||||||
self, event_type: Type[BluetoothDeviceEvent], device: BLEDevice, **kwargs
|
self, event_type: Type[BluetoothDeviceEvent], device: BLEDevice, **kwargs
|
||||||
):
|
):
|
||||||
|
if event_type == BluetoothDeviceNewDataEvent:
|
||||||
|
# Skip BluetoothDeviceNewDataEvent. They are only generated to
|
||||||
|
# infer if the device has sent some new data, but they can be very
|
||||||
|
# noisy and affect the latency - especially in case of connections
|
||||||
|
# that exchange a lot of data.
|
||||||
|
return
|
||||||
|
|
||||||
get_bus().post(
|
get_bus().post(
|
||||||
event_type(address=device.address, **parse_device_args(device), **kwargs)
|
event_type(address=device.address, **parse_device_args(device), **kwargs)
|
||||||
)
|
)
|
||||||
|
@ -254,6 +276,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
self._post_event(event_type, device)
|
self._post_event(event_type, device)
|
||||||
self._device_last_updated_at[device.address] = time()
|
self._device_last_updated_at[device.address] = time()
|
||||||
|
|
||||||
|
# Explicitly connect the child entities to their parent before
|
||||||
|
# flushing them
|
||||||
for child in entity.children:
|
for child in entity.children:
|
||||||
child.parent = entity
|
child.parent = entity
|
||||||
|
|
||||||
|
@ -284,20 +308,30 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
device: str,
|
device: str,
|
||||||
interface: Optional[str] = None,
|
interface: Optional[str] = None,
|
||||||
timeout: Optional[float] = None,
|
timeout: Optional[float] = None,
|
||||||
) -> AsyncGenerator[BleakClient, None]:
|
close_event: Optional[asyncio.Event] = None,
|
||||||
|
) -> AsyncGenerator[_BluetoothConnection, None]:
|
||||||
dev = await self._get_device(device)
|
dev = await self._get_device(device)
|
||||||
|
|
||||||
async with self._connection_locks.get(dev.address, Lock()) as lock:
|
async with self._connection_locks.get(dev.address, asyncio.Lock()) as lock:
|
||||||
self._connection_locks[dev.address] = lock or Lock()
|
self._connection_locks[dev.address] = lock or asyncio.Lock()
|
||||||
|
|
||||||
async with BleakClient(
|
async with BleakClient(
|
||||||
dev.address,
|
dev.address,
|
||||||
adapter=interface or self._interface,
|
adapter=interface or self._interface,
|
||||||
timeout=timeout or self._connect_timeout,
|
timeout=timeout or self._connect_timeout,
|
||||||
) as client:
|
) as client:
|
||||||
self._connections[dev.address] = client
|
assert client, f'Could not connect to the device {device}'
|
||||||
yield client
|
self.logger.info('Connected to device %s', device)
|
||||||
|
self._connections[dev.address] = _BluetoothConnection(
|
||||||
|
client=client,
|
||||||
|
device=dev,
|
||||||
|
loop=asyncio.get_event_loop(),
|
||||||
|
thread=threading.current_thread(),
|
||||||
|
close_event=close_event,
|
||||||
|
)
|
||||||
|
yield self._connections[dev.address]
|
||||||
self._connections.pop(dev.address, None)
|
self._connections.pop(dev.address, None)
|
||||||
|
self.logger.info('Disconnected from device %s', device)
|
||||||
|
|
||||||
async def _read(
|
async def _read(
|
||||||
self,
|
self,
|
||||||
|
@ -306,8 +340,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
interface: Optional[str] = None,
|
interface: Optional[str] = None,
|
||||||
connect_timeout: Optional[float] = None,
|
connect_timeout: Optional[float] = None,
|
||||||
) -> bytearray:
|
) -> bytearray:
|
||||||
async with self._connect(device, interface, connect_timeout) as client:
|
async with self._connect(device, interface, connect_timeout) as conn:
|
||||||
data = await client.read_gatt_char(service_uuid)
|
data = await conn.client.read_gatt_char(service_uuid)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -319,8 +353,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
interface: Optional[str] = None,
|
interface: Optional[str] = None,
|
||||||
connect_timeout: Optional[float] = None,
|
connect_timeout: Optional[float] = None,
|
||||||
):
|
):
|
||||||
async with self._connect(device, interface, connect_timeout) as client:
|
async with self._connect(device, interface, connect_timeout) as conn:
|
||||||
await client.write_gatt_char(service_uuid, data)
|
await conn.client.write_gatt_char(service_uuid, data)
|
||||||
|
|
||||||
async def _scan(
|
async def _scan(
|
||||||
self,
|
self,
|
||||||
|
@ -370,6 +404,65 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
self._scan_controller_timer = Timer(duration, timer_callback)
|
self._scan_controller_timer = Timer(duration, timer_callback)
|
||||||
self._scan_controller_timer.start()
|
self._scan_controller_timer.start()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
device: str,
|
||||||
|
interface: Optional[str] = None,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Pair and connect to a device by address or name.
|
||||||
|
|
||||||
|
:param device: The device address or name.
|
||||||
|
:param interface: The Bluetooth interface to use (it overrides the
|
||||||
|
default ``interface``).
|
||||||
|
:param timeout: The connection timeout in seconds (it overrides the
|
||||||
|
default ``connect_timeout``).
|
||||||
|
"""
|
||||||
|
timeout = timeout or self._connect_timeout
|
||||||
|
connected_event = threading.Event()
|
||||||
|
close_event = asyncio.Event()
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
def connect_thread():
|
||||||
|
async def connect_wrapper():
|
||||||
|
async with self._connect(device, interface, timeout, close_event):
|
||||||
|
connected_event.set()
|
||||||
|
await close_event.wait()
|
||||||
|
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_until_complete(connect_wrapper())
|
||||||
|
|
||||||
|
loop = get_or_create_event_loop()
|
||||||
|
connector = threading.Thread(target=connect_thread)
|
||||||
|
connector.start()
|
||||||
|
success = connected_event.wait(timeout=timeout)
|
||||||
|
assert success, f'Connection to {device} timed out'
|
||||||
|
|
||||||
|
@action
|
||||||
|
def disconnect(self, device: str):
|
||||||
|
"""
|
||||||
|
Close an active connection to a device.
|
||||||
|
|
||||||
|
:param device: The device address or name.
|
||||||
|
"""
|
||||||
|
address: Optional[str] = device
|
||||||
|
if not self._connections.get(device):
|
||||||
|
address = self._device_addr_by_name.get(device)
|
||||||
|
|
||||||
|
assert address, f'Device {device} not found'
|
||||||
|
connection = self._connections.get(address, None)
|
||||||
|
assert connection, f'No active connections to the device {device} were found'
|
||||||
|
|
||||||
|
if connection.close_event:
|
||||||
|
connection.close_event.set()
|
||||||
|
if connection.thread and connection.thread.is_alive():
|
||||||
|
connection.thread.join(timeout=5)
|
||||||
|
assert not (
|
||||||
|
connection.thread and connection.thread.is_alive()
|
||||||
|
), f'Disconnection from {device} timed out'
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def scan_pause(self, duration: Optional[float] = None):
|
def scan_pause(self, duration: Optional[float] = None):
|
||||||
"""
|
"""
|
||||||
|
@ -379,7 +472,9 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
(default: null = indefinitely).
|
(default: null = indefinitely).
|
||||||
"""
|
"""
|
||||||
if self._loop:
|
if self._loop:
|
||||||
ensure_future(self._scan_state_set(False, duration), loop=self._loop)
|
asyncio.ensure_future(
|
||||||
|
self._scan_state_set(False, duration), loop=self._loop
|
||||||
|
)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def scan_resume(self, duration: Optional[float] = None):
|
def scan_resume(self, duration: Optional[float] = None):
|
||||||
|
@ -390,7 +485,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
(default: null = indefinitely).
|
(default: null = indefinitely).
|
||||||
"""
|
"""
|
||||||
if self._loop:
|
if self._loop:
|
||||||
ensure_future(self._scan_state_set(True, duration), loop=self._loop)
|
asyncio.ensure_future(self._scan_state_set(True, duration), loop=self._loop)
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def scan(
|
def scan(
|
||||||
|
@ -518,6 +613,15 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
if self._scan_controller_timer:
|
if self._scan_controller_timer:
|
||||||
self._scan_controller_timer.cancel()
|
self._scan_controller_timer.cancel()
|
||||||
|
|
||||||
|
connections = list(self._connections.values())
|
||||||
|
for conn in connections:
|
||||||
|
try:
|
||||||
|
self.disconnect(conn.device.address)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'Error while disconnecting from %s: %s', conn.device.address, e
|
||||||
|
)
|
||||||
|
|
||||||
super().stop()
|
super().stop()
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue