- Added support for scan_pause/scan_resume on bluetooth integration.

- Added `BluetoothDevice` as its own entity type.
This commit is contained in:
Fabio Manganiello 2023-02-13 23:12:25 +01:00
parent 1d0be5c929
commit a3aa186ddf
Signed by: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 171 additions and 40 deletions

View file

@ -31,6 +31,14 @@
} }
}, },
"bluetooth_device": {
"name": "Device",
"name_plural": "Devices",
"icon": {
"class": "fab fa-bluetooth-b"
}
},
"device": { "device": {
"name": "Device", "name": "Device",
"name_plural": "Devices", "name_plural": "Devices",

View file

@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, Boolean, ForeignKey
from platypush.common.db import Base
from .devices import Device
if 'bluetooth_device' not in Base.metadata:
class BluetoothDevice(Device):
"""
Entity that represents a Bluetooth device.
"""
__tablename__ = 'bluetooth_device'
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
connected = Column(Boolean, default=False)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}

View file

@ -5,7 +5,31 @@ from platypush.message.event import Event
class BluetoothEvent(Event): class BluetoothEvent(Event):
""" """
Base class for Bluetooth Low-Energy device events. 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__( def __init__(
@ -17,7 +41,7 @@ class BluetoothEvent(Event):
trusted: bool, trusted: bool,
blocked: bool, blocked: bool,
name: Optional[str] = None, name: Optional[str] = None,
service_uuids: Optional[Collection[str]] = None, characteristics: Optional[Collection[str]] = None,
**kwargs **kwargs
): ):
""" """
@ -27,7 +51,8 @@ class BluetoothEvent(Event):
:param trusted: Whether the device is trusted. :param trusted: Whether the device is trusted.
:param blocked: Whether the device is blocked. :param blocked: Whether the device is blocked.
:param name: The name of the device. :param name: The name of the device.
:param service_uuids: The service UUIDs of the device. :param characteristics: The UUIDs of the characteristics exposed by the
device.
""" """
super().__init__( super().__init__(
*args, *args,
@ -37,66 +62,66 @@ class BluetoothEvent(Event):
paired=paired, paired=paired,
blocked=blocked, blocked=blocked,
trusted=trusted, trusted=trusted,
service_uuids=service_uuids or [], characteristics=characteristics or [],
**kwargs **kwargs
) )
class BluetoothDeviceFoundEvent(BluetoothEvent): class BluetoothDeviceFoundEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is discovered during a scan. Event triggered when a Bluetooth device is discovered during a scan.
""" """
class BluetoothDeviceLostEvent(BluetoothEvent): class BluetoothDeviceLostEvent(BluetoothDeviceEvent):
""" """
Event triggered when a previously discovered Bluetooth device is lost. Event triggered when a previously discovered Bluetooth device is lost.
""" """
class BluetoothDeviceConnectedEvent(BluetoothEvent): class BluetoothDeviceConnectedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is connected. Event triggered when a Bluetooth device is connected.
""" """
class BluetoothDeviceDisconnectedEvent(BluetoothEvent): class BluetoothDeviceDisconnectedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is disconnected. Event triggered when a Bluetooth device is disconnected.
""" """
class BluetoothDevicePairedEvent(BluetoothEvent): class BluetoothDevicePairedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is paired. Event triggered when a Bluetooth device is paired.
""" """
class BluetoothDeviceUnpairedEvent(BluetoothEvent): class BluetoothDeviceUnpairedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is unpaired. Event triggered when a Bluetooth device is unpaired.
""" """
class BluetoothDeviceBlockedEvent(BluetoothEvent): class BluetoothDeviceBlockedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is blocked. Event triggered when a Bluetooth device is blocked.
""" """
class BluetoothDeviceUnblockedEvent(BluetoothEvent): class BluetoothDeviceUnblockedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is unblocked. Event triggered when a Bluetooth device is unblocked.
""" """
class BluetoothDeviceTrustedEvent(BluetoothEvent): class BluetoothDeviceTrustedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is trusted. Event triggered when a Bluetooth device is trusted.
""" """
class BluetoothDeviceUntrustedEvent(BluetoothEvent): class BluetoothDeviceUntrustedEvent(BluetoothDeviceEvent):
""" """
Event triggered when a Bluetooth device is untrusted. Event triggered when a Bluetooth device is untrusted.
""" """

View file

@ -167,7 +167,7 @@ class AsyncRunnablePlugin(RunnablePlugin, ABC):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = asyncio.new_event_loop()
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
@property @property
@ -204,7 +204,6 @@ class AsyncRunnablePlugin(RunnablePlugin, ABC):
""" """
Initialize an event loop and run the listener as a task. Initialize an event loop and run the listener as a task.
""" """
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop) asyncio.set_event_loop(self._loop)
self._task = self._loop.create_task(self._listen()) self._task = self._loop.create_task(self._listen())

View file

@ -1,6 +1,7 @@
import base64 import base64
from asyncio import Event, ensure_future
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from threading import RLock from threading import RLock, Timer
from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union
from uuid import UUID from uuid import UUID
@ -10,7 +11,7 @@ from typing_extensions import override
from platypush.context import get_bus, get_or_create_event_loop from platypush.context import get_bus, get_or_create_event_loop
from platypush.entities import Entity, EntityManager from platypush.entities import Entity, EntityManager
from platypush.entities.devices import Device from platypush.entities.bluetooth import BluetoothDevice
from platypush.message.event.bluetooth.ble import ( from platypush.message.event.bluetooth.ble import (
BluetoothDeviceBlockedEvent, BluetoothDeviceBlockedEvent,
BluetoothDeviceConnectedEvent, BluetoothDeviceConnectedEvent,
@ -22,7 +23,9 @@ from platypush.message.event.bluetooth.ble import (
BluetoothDeviceUnblockedEvent, BluetoothDeviceUnblockedEvent,
BluetoothDeviceUnpairedEvent, BluetoothDeviceUnpairedEvent,
BluetoothDeviceUntrustedEvent, BluetoothDeviceUntrustedEvent,
BluetoothEvent, BluetoothDeviceEvent,
BluetoothScanPausedEvent,
BluetoothScanResumedEvent,
) )
from platypush.plugins import AsyncRunnablePlugin, action from platypush.plugins import AsyncRunnablePlugin, action
@ -52,7 +55,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
interface: Optional[str] = None, interface: Optional[str] = None,
connect_timeout: float = _default_connect_timeout, connect_timeout: float = _default_connect_timeout,
device_names: Optional[Dict[str, str]] = None, device_names: Optional[Dict[str, str]] = None,
service_uuids: Optional[Collection[UUIDType]] = None, characteristics: Optional[Collection[UUIDType]] = None,
scan_paused_on_start: bool = False,
**kwargs, **kwargs,
): ):
""" """
@ -60,7 +64,8 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
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: 5 seconds.
:param service_uuids: List of service UUIDs to discover. Default: all. :param characteristics: List of service/characteristic UUIDs to
discover. Default: all.
:param device_names: Bluetooth address -> device name mapping. If not :param device_names: Bluetooth address -> device name mapping. If not
specified, the device's advertised name will be used, or its specified, the device's advertised name will be used, or its
Bluetooth address. Example: Bluetooth address. Example:
@ -73,13 +78,19 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
"00:11:22:33:44:57": "Button" "00:11:22:33:44:57": "Button"
} }
:param scan_paused_on_start: If ``True``, the plugin will not the
scanning thread until :meth:`.scan_resume` is called (default:
``False``).
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self._interface = interface self._interface = interface
self._connect_timeout = connect_timeout self._connect_timeout = connect_timeout
self._service_uuids = service_uuids self._characteristics = characteristics
self._scan_lock = RLock() self._scan_lock = RLock()
self._scan_enabled = Event()
self._scan_controller_timer: Optional[Timer] = None
self._connections: Dict[str, BleakClient] = {} self._connections: Dict[str, BleakClient] = {}
self._devices: Dict[str, BLEDevice] = {} self._devices: Dict[str, BLEDevice] = {}
self._device_name_by_addr = device_names or {} self._device_name_by_addr = device_names or {}
@ -87,6 +98,9 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
name: addr for addr, name in self._device_name_by_addr.items() name: addr for addr, name in self._device_name_by_addr.items()
} }
if not scan_paused_on_start:
self._scan_enabled.set()
async def _get_device(self, device: str) -> BLEDevice: async def _get_device(self, device: str) -> BLEDevice:
""" """
Utility method to get a device by name or address. Utility method to get a device by name or address.
@ -113,7 +127,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
) )
def _post_event( def _post_event(
self, event_type: Type[BluetoothEvent], device: BLEDevice, **kwargs self, event_type: Type[BluetoothDeviceEvent], device: BLEDevice, **kwargs
): ):
props = device.details.get('props', {}) props = device.details.get('props', {})
get_bus().post( get_bus().post(
@ -124,13 +138,13 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
paired=props.get('Paired', False), paired=props.get('Paired', False),
blocked=props.get('Blocked', False), blocked=props.get('Blocked', False),
trusted=props.get('Trusted', False), trusted=props.get('Trusted', False),
service_uuids=device.metadata.get('uuids', []), characteristics=device.metadata.get('uuids', []),
**kwargs, **kwargs,
) )
) )
def _on_device_event(self, device: BLEDevice, _): def _on_device_event(self, device: BLEDevice, _):
event_types: List[Type[BluetoothEvent]] = [] event_types: List[Type[BluetoothDeviceEvent]] = []
existing_device = self._devices.get(device.address) existing_device = self._devices.get(device.address)
if existing_device: if existing_device:
@ -168,6 +182,9 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
event_types.append(BluetoothDeviceFoundEvent) event_types.append(BluetoothDeviceFoundEvent)
self._devices[device.address] = device self._devices[device.address] = device
if device.name:
self._device_name_by_addr[device.address] = device.name
self._device_addr_by_name[device.name] = device.address
if event_types: if event_types:
for event_type in event_types: for event_type in event_types:
@ -217,7 +234,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
async def _scan( async def _scan(
self, self,
duration: Optional[float] = None, duration: Optional[float] = None,
service_uuids: Optional[Collection[UUIDType]] = None, characteristics: Optional[Collection[UUIDType]] = None,
publish_entities: bool = False, publish_entities: bool = False,
) -> Collection[Entity]: ) -> Collection[Entity]:
with self._scan_lock: with self._scan_lock:
@ -226,7 +243,7 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
adapter=self._interface, adapter=self._interface,
timeout=timeout, timeout=timeout,
service_uuids=list( service_uuids=list(
map(str, service_uuids or self._service_uuids or []) map(str, characteristics or self._characteristics or [])
), ),
detection_callback=self._on_device_event, detection_callback=self._on_device_event,
) )
@ -234,29 +251,75 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
# TODO Infer type from device.metadata['manufacturer_data'] # TODO Infer type from device.metadata['manufacturer_data']
self._devices.update({dev.address: dev for dev in devices}) self._devices.update({dev.address: dev for dev in devices})
if publish_entities: return (
entities = self.publish_entities(devices) self.publish_entities(devices)
else: if publish_entities
entities = self.transform_entities(devices) else self.transform_entities(devices)
)
return entities async def _scan_state_set(self, state: bool, duration: Optional[float] = None):
def timer_callback():
if state:
self.scan_pause()
else:
self.scan_resume()
self._scan_controller_timer = None
with self._scan_lock:
if not state and self._scan_enabled.is_set():
get_bus().post(BluetoothScanPausedEvent(duration=duration))
elif state and not self._scan_enabled.is_set():
get_bus().post(BluetoothScanResumedEvent(duration=duration))
if state:
self._scan_enabled.set()
else:
self._scan_enabled.clear()
if duration and not self._scan_controller_timer:
self._scan_controller_timer = Timer(duration, timer_callback)
self._scan_controller_timer.start()
@action
def scan_pause(self, duration: Optional[float] = None):
"""
Pause the scanning thread.
:param duration: For how long the scanning thread should be paused
(default: null = indefinitely).
"""
if self._loop:
ensure_future(self._scan_state_set(False, duration), loop=self._loop)
@action
def scan_resume(self, duration: Optional[float] = None):
"""
Resume the scanning thread, if inactive.
:param duration: For how long the scanning thread should be running
(default: null = indefinitely).
"""
if self._loop:
ensure_future(self._scan_state_set(True, duration), loop=self._loop)
@action @action
def scan( def scan(
self, self,
duration: Optional[float] = None, duration: Optional[float] = None,
service_uuids: Optional[Collection[UUIDType]] = None, characteristics: Optional[Collection[UUIDType]] = None,
): ):
""" """
Scan for Bluetooth devices nearby. Scan for Bluetooth devices nearby and return the results as a list of
entities.
:param duration: Scan duration in seconds (default: same as the plugin's :param duration: Scan duration in seconds (default: same as the plugin's
`poll_interval` configuration parameter) `poll_interval` configuration parameter)
:param service_uuids: List of service UUIDs to discover. Default: all. :param characteristics: List of characteristic UUIDs to discover. Default: all.
""" """
loop = get_or_create_event_loop() loop = get_or_create_event_loop()
loop.run_until_complete( return loop.run_until_complete(
self._scan(duration, service_uuids, publish_entities=True) self._scan(duration, characteristics, publish_entities=True)
) )
@action @action
@ -319,9 +382,11 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
return self.scan().output return self.scan().output
@override @override
def transform_entities(self, entities: Collection[BLEDevice]) -> Collection[Device]: def transform_entities(
self, entities: Collection[BLEDevice]
) -> Collection[BluetoothDevice]:
return [ return [
Device( BluetoothDevice(
id=dev.address, id=dev.address,
name=self._get_device_name(dev), name=self._get_device_name(dev),
) )
@ -333,7 +398,9 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
device_addresses = set() device_addresses = set()
while True: while True:
await self._scan_enabled.wait()
entities = await self._scan() entities = await self._scan()
new_device_addresses = {e.id for e in entities} new_device_addresses = {e.id for e in entities}
missing_device_addresses = device_addresses - new_device_addresses missing_device_addresses = device_addresses - new_device_addresses
missing_devices = [ missing_devices = [
@ -348,5 +415,12 @@ class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
device_addresses = new_device_addresses device_addresses = new_device_addresses
@override
def stop(self):
if self._scan_controller_timer:
self._scan_controller_timer.cancel()
super().stop()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -54,7 +54,7 @@ class SwitchbotBluetoothPlugin(BluetoothBlePlugin, EnumSwitchEntityManager):
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, service_uuids=self._uuids.values(), **kwargs) super().__init__(*args, characteristics=self._uuids.values(), **kwargs)
async def _run( async def _run(
self, self,