forked from platypush/platypush
Rewriting bluetooth.ble
plugin to use bleak
instead of gattlib
.
This commit is contained in:
parent
f30e077a5a
commit
b0cc80ceb0
17 changed files with 538 additions and 505 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
``bluetooth.scanner.ble``
|
||||
===========================================
|
||||
|
||||
.. automodule:: platypush.backend.bluetooth.scanner.ble
|
||||
:members:
|
|
@ -1,5 +0,0 @@
|
|||
``zigbee.mqtt``
|
||||
=================================
|
||||
|
||||
.. automodule:: platypush.backend.zigbee.mqtt
|
||||
:members:
|
5
docs/source/platypush/events/bluetooth.ble.rst
Normal file
5
docs/source/platypush/events/bluetooth.ble.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``bluetooth.ble``
|
||||
=================
|
||||
|
||||
.. automodule:: platypush.message.event.bluetooth.ble
|
||||
:members:
|
|
@ -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:
|
|
@ -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
|
|
@ -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',
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
75
platypush/message/event/bluetooth/__init__.py
Normal file
75
platypush/message/event/bluetooth/__init__.py
Normal 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:
|
104
platypush/message/event/bluetooth/ble.py
Normal file
104
platypush/message/event/bluetooth/ble.py
Normal 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:
|
|
@ -206,8 +206,12 @@ class AsyncRunnablePlugin(RunnablePlugin, ABC):
|
|||
self._loop = asyncio.new_event_loop()
|
||||
|
||||
if self._should_start_runner:
|
||||
while not self.should_stop():
|
||||
try:
|
||||
self._run_listener()
|
||||
|
||||
finally:
|
||||
self.wait_stop(self.poll_interval)
|
||||
else:
|
||||
self.wait_stop()
|
||||
|
||||
def stop(self):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -2,7 +2,6 @@ manifest:
|
|||
events: {}
|
||||
install:
|
||||
pip:
|
||||
- pybluez
|
||||
- gattlib
|
||||
- bleak
|
||||
package: platypush.plugins.bluetooth.ble
|
||||
type: plugin
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue