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.fileserver.rst
|
||||||
platypush/backend/bluetooth.pushserver.rst
|
platypush/backend/bluetooth.pushserver.rst
|
||||||
platypush/backend/bluetooth.scanner.rst
|
platypush/backend/bluetooth.scanner.rst
|
||||||
platypush/backend/bluetooth.scanner.ble.rst
|
|
||||||
platypush/backend/button.flic.rst
|
platypush/backend/button.flic.rst
|
||||||
platypush/backend/camera.pi.rst
|
platypush/backend/camera.pi.rst
|
||||||
platypush/backend/chat.telegram.rst
|
platypush/backend/chat.telegram.rst
|
||||||
|
@ -74,6 +73,5 @@ Backends
|
||||||
platypush/backend/weather.openweathermap.rst
|
platypush/backend/weather.openweathermap.rst
|
||||||
platypush/backend/websocket.rst
|
platypush/backend/websocket.rst
|
||||||
platypush/backend/wiimote.rst
|
platypush/backend/wiimote.rst
|
||||||
platypush/backend/zigbee.mqtt.rst
|
|
||||||
platypush/backend/zwave.rst
|
platypush/backend/zwave.rst
|
||||||
platypush/backend/zwave.mqtt.rst
|
platypush/backend/zwave.mqtt.rst
|
||||||
|
|
|
@ -11,6 +11,7 @@ Events
|
||||||
platypush/events/application.rst
|
platypush/events/application.rst
|
||||||
platypush/events/assistant.rst
|
platypush/events/assistant.rst
|
||||||
platypush/events/bluetooth.rst
|
platypush/events/bluetooth.rst
|
||||||
|
platypush/events/bluetooth.ble.rst
|
||||||
platypush/events/button.flic.rst
|
platypush/events/button.flic.rst
|
||||||
platypush/events/camera.rst
|
platypush/events/camera.rst
|
||||||
platypush/events/chat.slack.rst
|
platypush/events/chat.slack.rst
|
||||||
|
|
|
@ -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 ._base import Entity, get_entities_registry, init_entities_db
|
||||||
from ._engine import EntitiesEngine
|
from ._engine import EntitiesEngine
|
||||||
from ._managers import register_entity_manager, get_plugin_entity_registry
|
from ._managers import (
|
||||||
|
EntityManager,
|
||||||
|
get_plugin_entity_registry,
|
||||||
|
register_entity_manager,
|
||||||
|
)
|
||||||
from ._managers.lights import LightEntityManager
|
from ._managers.lights import LightEntityManager
|
||||||
from ._managers.sensors import SensorEntityManager
|
from ._managers.sensors import SensorEntityManager
|
||||||
from ._managers.switches import (
|
from ._managers.switches import (
|
||||||
|
@ -50,6 +54,7 @@ __all__ = (
|
||||||
'DimmerEntityManager',
|
'DimmerEntityManager',
|
||||||
'EntitiesEngine',
|
'EntitiesEngine',
|
||||||
'Entity',
|
'Entity',
|
||||||
|
'EntityManager',
|
||||||
'EnumSwitchEntityManager',
|
'EnumSwitchEntityManager',
|
||||||
'LightEntityManager',
|
'LightEntityManager',
|
||||||
'SensorEntityManager',
|
'SensorEntityManager',
|
||||||
|
|
|
@ -109,11 +109,7 @@ def register_entity_manager(cls: Type[EntityManager]):
|
||||||
Associates a plugin as a manager for a certain entity type.
|
Associates a plugin as a manager for a certain entity type.
|
||||||
You usually don't have to call this method directly.
|
You usually don't have to call this method directly.
|
||||||
"""
|
"""
|
||||||
entity_managers = [
|
entity_managers = [c for c in inspect.getmro(cls) if issubclass(c, EntityManager)]
|
||||||
c
|
|
||||||
for c in inspect.getmro(cls)
|
|
||||||
if issubclass(c, EntityManager) and c not in {cls, EntityManager}
|
|
||||||
]
|
|
||||||
|
|
||||||
plugin_name = get_plugin_name_by_class(cls) or ''
|
plugin_name = get_plugin_name_by_class(cls) or ''
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
|
|
|
@ -35,7 +35,7 @@ class MultiLevelSwitchEntityManager(EntityManager, ABC):
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def set_value( # pylint: disable=redefined-builtin
|
def set_value( # pylint: disable=redefined-builtin
|
||||||
self, *_, property=None, data=None, **__
|
self, device=None, property=None, *, data=None, **__
|
||||||
):
|
):
|
||||||
"""Set a value"""
|
"""Set a value"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
|
@ -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()
|
self._loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
if self._should_start_runner:
|
if self._should_start_runner:
|
||||||
|
while not self.should_stop():
|
||||||
|
try:
|
||||||
self._run_listener()
|
self._run_listener()
|
||||||
|
finally:
|
||||||
|
self.wait_stop(self.poll_interval)
|
||||||
|
else:
|
||||||
self.wait_stop()
|
self.wait_stop()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|
|
@ -1,275 +1,352 @@
|
||||||
import base64
|
import base64
|
||||||
import os
|
from contextlib import asynccontextmanager
|
||||||
import subprocess
|
from threading import RLock
|
||||||
import sys
|
from typing import AsyncGenerator, Collection, List, Optional, Dict, Type, Union
|
||||||
import time
|
from uuid import UUID
|
||||||
from typing import Optional, Dict
|
|
||||||
|
|
||||||
from platypush.plugins import action
|
from bleak import BleakClient, BleakScanner
|
||||||
from platypush.plugins.sensor import SensorPlugin
|
from bleak.backends.device import BLEDevice
|
||||||
from platypush.message.response.bluetooth import BluetoothScanResponse, BluetoothDiscoverPrimaryResponse, \
|
from typing_extensions import override
|
||||||
BluetoothDiscoverCharacteristicsResponse
|
|
||||||
|
from platypush.context import get_bus, get_or_create_event_loop
|
||||||
|
from platypush.entities import Entity, EntityManager
|
||||||
|
from platypush.entities.devices import Device
|
||||||
|
from platypush.message.event.bluetooth.ble import (
|
||||||
|
BluetoothDeviceBlockedEvent,
|
||||||
|
BluetoothDeviceConnectedEvent,
|
||||||
|
BluetoothDeviceDisconnectedEvent,
|
||||||
|
BluetoothDeviceFoundEvent,
|
||||||
|
BluetoothDeviceLostEvent,
|
||||||
|
BluetoothDevicePairedEvent,
|
||||||
|
BluetoothDeviceTrustedEvent,
|
||||||
|
BluetoothDeviceUnblockedEvent,
|
||||||
|
BluetoothDeviceUnpairedEvent,
|
||||||
|
BluetoothDeviceUntrustedEvent,
|
||||||
|
BluetoothEvent,
|
||||||
|
)
|
||||||
|
from platypush.plugins import AsyncRunnablePlugin, action
|
||||||
|
|
||||||
|
UUIDType = Union[str, UUID]
|
||||||
|
|
||||||
|
|
||||||
class BluetoothBlePlugin(SensorPlugin):
|
class BluetoothBlePlugin(AsyncRunnablePlugin, EntityManager):
|
||||||
"""
|
"""
|
||||||
Bluetooth BLE (low-energy) plugin
|
Plugin to interact with BLE (Bluetooth Low-Energy) devices.
|
||||||
|
|
||||||
|
Note that the support for Bluetooth low-energy devices requires a Bluetooth
|
||||||
|
adapter compatible with the Bluetooth 5.0 specification or higher.
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
|
|
||||||
* **pybluez** (``pip install pybluez``)
|
* **bleak** (``pip install bleak``)
|
||||||
* **gattlib** (``pip install gattlib``)
|
|
||||||
|
|
||||||
Note that the support for bluetooth low-energy devices on Linux requires:
|
TODO: Write supported events.
|
||||||
|
|
||||||
* A bluetooth adapter compatible with the bluetooth 5.0 specification or higher;
|
|
||||||
* To run platypush with root privileges (which is usually a very bad idea), or to set the raw net
|
|
||||||
capabilities on the Python executable (which is also a bad idea, because any Python script will
|
|
||||||
be able to access the kernel raw network API, but it's probably better than running a network
|
|
||||||
server that can execute system commands as root user). If you don't want to set special permissions
|
|
||||||
on the main Python executable and you want to run the bluetooth LTE plugin then the advised approach
|
|
||||||
is to install platypush in a virtual environment and set the capabilities on the venv python executable,
|
|
||||||
or run your platypush instance in Docker.
|
|
||||||
|
|
||||||
You can set the capabilities on the Python executable through the following shell command::
|
|
||||||
|
|
||||||
[sudo] setcap 'cap_net_raw,cap_net_admin+eip' /path/to/your/python
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, interface: str = 'hci0', **kwargs):
|
# Default connection timeout (in seconds)
|
||||||
|
_default_connect_timeout = 5
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
interface: Optional[str] = None,
|
||||||
|
connect_timeout: float = _default_connect_timeout,
|
||||||
|
device_names: Optional[Dict[str, str]] = None,
|
||||||
|
service_uuids: Optional[Collection[UUIDType]] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
:param interface: Default adapter device to be used (default: 'hci0')
|
:param interface: Name of the Bluetooth interface to use (e.g. ``hci0``
|
||||||
|
on Linux). Default: first available interface.
|
||||||
|
:param connect_timeout: Timeout in seconds for the connection to a
|
||||||
|
Bluetooth device. Default: 5 seconds.
|
||||||
|
:param service_uuids: List of service UUIDs to discover. Default: all.
|
||||||
|
:param device_names: Bluetooth address -> device name mapping. If not
|
||||||
|
specified, the device's advertised name will be used, or its
|
||||||
|
Bluetooth address. Example:
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
{
|
||||||
|
"00:11:22:33:44:55": "Switchbot",
|
||||||
|
"00:11:22:33:44:56": "Headphones",
|
||||||
|
"00:11:22:33:44:57": "Button"
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.interface = interface
|
|
||||||
self._req_by_addr = {}
|
|
||||||
|
|
||||||
@staticmethod
|
self._interface = interface
|
||||||
def _get_python_interpreter() -> str:
|
self._connect_timeout = connect_timeout
|
||||||
exe = sys.executable
|
self._service_uuids = service_uuids
|
||||||
|
self._scan_lock = RLock()
|
||||||
|
self._connections: Dict[str, BleakClient] = {}
|
||||||
|
self._devices: Dict[str, BLEDevice] = {}
|
||||||
|
self._device_name_by_addr = device_names or {}
|
||||||
|
self._device_addr_by_name = {
|
||||||
|
name: addr for addr, name in self._device_name_by_addr.items()
|
||||||
|
}
|
||||||
|
|
||||||
while os.path.islink(exe):
|
async def _get_device(self, device: str) -> BLEDevice:
|
||||||
target = os.readlink(exe)
|
|
||||||
if not os.path.isabs(target):
|
|
||||||
target = os.path.abspath(os.path.join(os.path.dirname(exe), target))
|
|
||||||
exe = target
|
|
||||||
|
|
||||||
return exe
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _python_has_ble_capabilities(exe: str) -> bool:
|
|
||||||
getcap = subprocess.Popen(['getcap', exe], stdout=subprocess.PIPE)
|
|
||||||
output = getcap.communicate()[0].decode().split('\n')
|
|
||||||
if not output:
|
|
||||||
return False
|
|
||||||
|
|
||||||
caps = output[0]
|
|
||||||
return ('cap_net_raw+eip' in caps or 'cap_net_raw=eip' in caps) and 'cap_net_admin' in caps
|
|
||||||
|
|
||||||
def _check_ble_support(self):
|
|
||||||
# Check if the script is running as root or if the Python executable
|
|
||||||
# has 'cap_net_admin,cap_net_raw+eip' capabilities
|
|
||||||
exe = self._get_python_interpreter()
|
|
||||||
assert os.getuid() == 0 or self._python_has_ble_capabilities(exe), '''
|
|
||||||
You are not running platypush as root and the Python interpreter has no
|
|
||||||
capabilities/permissions to access the BLE stack. Set the permissions on
|
|
||||||
your Python interpreter through:
|
|
||||||
|
|
||||||
[sudo] setcap "cap_net_raw,cap_net_admin+eip" {}'''.format(exe)
|
|
||||||
|
|
||||||
@action
|
|
||||||
def scan(self, interface: Optional[str] = None, duration: int = 10) -> BluetoothScanResponse:
|
|
||||||
"""
|
"""
|
||||||
Scan for nearby bluetooth low-energy devices
|
Utility method to get a device by name or address.
|
||||||
|
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
|
||||||
:param duration: Scan duration in seconds
|
|
||||||
"""
|
"""
|
||||||
from bluetooth.ble import DiscoveryService
|
addr = (
|
||||||
|
self._device_addr_by_name[device]
|
||||||
|
if device in self._device_addr_by_name
|
||||||
|
else device
|
||||||
|
)
|
||||||
|
|
||||||
if interface is None:
|
if addr not in self._devices:
|
||||||
interface = self.interface
|
self.logger.info('Scanning for unknown device "%s"', device)
|
||||||
|
await self._scan()
|
||||||
|
|
||||||
self._check_ble_support()
|
dev = self._devices.get(addr)
|
||||||
svc = DiscoveryService(interface)
|
assert dev is not None, f'Unknown device: "{device}"'
|
||||||
devices = svc.discover(duration)
|
return dev
|
||||||
return BluetoothScanResponse(devices)
|
|
||||||
|
|
||||||
@action
|
def _get_device_name(self, device: BLEDevice) -> str:
|
||||||
def get_measurement(self, interface: Optional[str] = None, duration: Optional[int] = 10, *args, **kwargs) \
|
return (
|
||||||
-> Dict[str, dict]:
|
self._device_name_by_addr.get(device.address)
|
||||||
"""
|
or device.name
|
||||||
Wrapper for ``scan`` that returns bluetooth devices in a format usable by sensor backends.
|
or device.address
|
||||||
|
)
|
||||||
|
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
def _post_event(
|
||||||
:param duration: Scan duration in seconds
|
self, event_type: Type[BluetoothEvent], device: BLEDevice, **kwargs
|
||||||
:return: Device address -> info map.
|
):
|
||||||
"""
|
props = device.details.get('props', {})
|
||||||
devices = self.scan(interface=interface, duration=duration).output
|
get_bus().post(
|
||||||
return {device['addr']: device for device in devices}
|
event_type(
|
||||||
|
address=device.address,
|
||||||
|
name=self._get_device_name(device),
|
||||||
|
connected=props.get('Connected', False),
|
||||||
|
paired=props.get('Paired', False),
|
||||||
|
blocked=props.get('Blocked', False),
|
||||||
|
trusted=props.get('Trusted', False),
|
||||||
|
service_uuids=device.metadata.get('uuids', []),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# noinspection PyArgumentList
|
def _on_device_event(self, device: BLEDevice, _):
|
||||||
@action
|
event_types: List[Type[BluetoothEvent]] = []
|
||||||
def connect(self, device: str, interface: str = None, wait: bool = True, channel_type: str = 'public',
|
existing_device = self._devices.get(device.address)
|
||||||
security_level: str = 'low', psm: int = 0, mtu: int = 0, timeout: float = 10.0):
|
|
||||||
"""
|
|
||||||
Connect to a bluetooth LE device
|
|
||||||
|
|
||||||
:param device: Device address to connect to
|
if existing_device:
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
old_props = existing_device.details.get('props', {})
|
||||||
:param wait: If True then wait for the connection to be established before returning (no timeout)
|
new_props = device.details.get('props', {})
|
||||||
:param channel_type: Channel type, usually 'public' or 'random'
|
|
||||||
:param security_level: Security level - possible values: ['low', 'medium', 'high']
|
|
||||||
:param psm: PSM value (default: 0)
|
|
||||||
:param mtu: MTU value (default: 0)
|
|
||||||
:param timeout: Connection timeout if wait is not set (default: 10 seconds)
|
|
||||||
"""
|
|
||||||
from gattlib import GATTRequester
|
|
||||||
|
|
||||||
req = self._req_by_addr.get(device)
|
if old_props.get('Paired') != new_props.get('Paired'):
|
||||||
if req:
|
event_types.append(
|
||||||
if req.is_connected():
|
BluetoothDevicePairedEvent
|
||||||
self.logger.info('Device {} is already connected'.format(device))
|
if new_props.get('Paired')
|
||||||
return
|
else BluetoothDeviceUnpairedEvent
|
||||||
|
)
|
||||||
|
|
||||||
self._req_by_addr[device] = None
|
if old_props.get('Connected') != new_props.get('Connected'):
|
||||||
|
event_types.append(
|
||||||
|
BluetoothDeviceConnectedEvent
|
||||||
|
if new_props.get('Connected')
|
||||||
|
else BluetoothDeviceDisconnectedEvent
|
||||||
|
)
|
||||||
|
|
||||||
if not interface:
|
if old_props.get('Blocked') != new_props.get('Blocked'):
|
||||||
interface = self.interface
|
event_types.append(
|
||||||
if interface:
|
BluetoothDeviceBlockedEvent
|
||||||
req = GATTRequester(device, False, interface)
|
if new_props.get('Blocked')
|
||||||
|
else BluetoothDeviceUnblockedEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_props.get('Trusted') != new_props.get('Trusted'):
|
||||||
|
event_types.append(
|
||||||
|
BluetoothDeviceTrustedEvent
|
||||||
|
if new_props.get('Trusted')
|
||||||
|
else BluetoothDeviceUntrustedEvent
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
req = GATTRequester(device, False)
|
event_types.append(BluetoothDeviceFoundEvent)
|
||||||
|
|
||||||
self.logger.info('Connecting to {}'.format(device))
|
self._devices[device.address] = device
|
||||||
connect_start_time = time.time()
|
|
||||||
req.connect(wait, channel_type, security_level, psm, mtu)
|
|
||||||
|
|
||||||
if not wait:
|
if event_types:
|
||||||
while not req.is_connected():
|
for event_type in event_types:
|
||||||
if time.time() - connect_start_time > timeout:
|
self._post_event(event_type, device)
|
||||||
raise TimeoutError('Connection to {} timed out'.format(device))
|
self.publish_entities([device])
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
self.logger.info('Connected to {}'.format(device))
|
@asynccontextmanager
|
||||||
self._req_by_addr[device] = req
|
async def _connect(
|
||||||
|
self,
|
||||||
|
device: str,
|
||||||
|
interface: Optional[str] = None,
|
||||||
|
timeout: Optional[float] = None,
|
||||||
|
) -> AsyncGenerator[BleakClient, None]:
|
||||||
|
dev = await self._get_device(device)
|
||||||
|
async with BleakClient(
|
||||||
|
dev.address,
|
||||||
|
adapter=interface or self._interface,
|
||||||
|
timeout=timeout or self._connect_timeout,
|
||||||
|
) as client:
|
||||||
|
self._connections[dev.address] = client
|
||||||
|
yield client
|
||||||
|
self._connections.pop(dev.address)
|
||||||
|
|
||||||
@action
|
async def _read(
|
||||||
def read(self, device: str, interface: str = None, uuid: str = None, handle: int = None,
|
self,
|
||||||
binary: bool = False, disconnect_on_recv: bool = True, **kwargs) -> str:
|
device: str,
|
||||||
"""
|
service_uuid: UUIDType,
|
||||||
Read a message from a device
|
interface: Optional[str] = None,
|
||||||
|
connect_timeout: Optional[float] = None,
|
||||||
:param device: Device address to connect to
|
) -> bytearray:
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
async with self._connect(device, interface, connect_timeout) as client:
|
||||||
:param uuid: Service UUID. Either the UUID or the device handle must be specified
|
data = await client.read_gatt_char(service_uuid)
|
||||||
:param handle: Device handle. Either the UUID or the device handle must be specified
|
|
||||||
:param binary: Set to true to return data as a base64-encoded binary string
|
|
||||||
:param disconnect_on_recv: If True (default) disconnect when the response is received
|
|
||||||
:param kwargs: Extra arguments to be passed to :meth:`connect`
|
|
||||||
"""
|
|
||||||
if interface is None:
|
|
||||||
interface = self.interface
|
|
||||||
if not (uuid or handle):
|
|
||||||
raise AttributeError('Specify either uuid or handle')
|
|
||||||
|
|
||||||
self.connect(device, interface=interface, **kwargs)
|
|
||||||
req = self._req_by_addr[device]
|
|
||||||
|
|
||||||
if uuid:
|
|
||||||
data = req.read_by_uuid(uuid)[0]
|
|
||||||
else:
|
|
||||||
data = req.read_by_handle(handle)[0]
|
|
||||||
|
|
||||||
if binary:
|
|
||||||
data = base64.encodebytes(data.encode() if isinstance(data, str) else data).decode().strip()
|
|
||||||
if disconnect_on_recv:
|
|
||||||
self.disconnect(device)
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
async def _write(
|
||||||
|
self,
|
||||||
|
device: str,
|
||||||
|
data: bytes,
|
||||||
|
service_uuid: UUIDType,
|
||||||
|
interface: Optional[str] = None,
|
||||||
|
connect_timeout: Optional[float] = None,
|
||||||
|
):
|
||||||
|
async with self._connect(device, interface, connect_timeout) as client:
|
||||||
|
await client.write_gatt_char(service_uuid, data)
|
||||||
|
|
||||||
|
async def _scan(
|
||||||
|
self,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
service_uuids: Optional[Collection[UUIDType]] = None,
|
||||||
|
publish_entities: bool = False,
|
||||||
|
) -> Collection[Entity]:
|
||||||
|
with self._scan_lock:
|
||||||
|
timeout = duration or self.poll_interval or 5
|
||||||
|
devices = await BleakScanner.discover(
|
||||||
|
adapter=self._interface,
|
||||||
|
timeout=timeout,
|
||||||
|
service_uuids=list(
|
||||||
|
map(str, service_uuids or self._service_uuids or [])
|
||||||
|
),
|
||||||
|
detection_callback=self._on_device_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO Infer type from device.metadata['manufacturer_data']
|
||||||
|
|
||||||
|
self._devices.update({dev.address: dev for dev in devices})
|
||||||
|
if publish_entities:
|
||||||
|
entities = self.publish_entities(devices)
|
||||||
|
else:
|
||||||
|
entities = self.transform_entities(devices)
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def write(self, device: str, data, handle: int = None, interface: str = None, binary: bool = False,
|
def scan(
|
||||||
disconnect_on_recv: bool = True, **kwargs) -> str:
|
self,
|
||||||
|
duration: Optional[float] = None,
|
||||||
|
service_uuids: Optional[Collection[UUIDType]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Scan for Bluetooth devices nearby.
|
||||||
|
|
||||||
|
:param duration: Scan duration in seconds (default: same as the plugin's
|
||||||
|
`poll_interval` configuration parameter)
|
||||||
|
:param service_uuids: List of service UUIDs to discover. Default: all.
|
||||||
|
"""
|
||||||
|
loop = get_or_create_event_loop()
|
||||||
|
loop.run_until_complete(
|
||||||
|
self._scan(duration, service_uuids, publish_entities=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
@action
|
||||||
|
def read(
|
||||||
|
self,
|
||||||
|
device: str,
|
||||||
|
service_uuid: UUIDType,
|
||||||
|
interface: Optional[str] = None,
|
||||||
|
connect_timeout: Optional[float] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Read a message from a device.
|
||||||
|
|
||||||
|
:param device: Name or address of the device to read from.
|
||||||
|
:param service_uuid: Service UUID.
|
||||||
|
:param interface: Bluetooth adapter name to use (default configured if None).
|
||||||
|
:param connect_timeout: Connection timeout in seconds (default: same as the
|
||||||
|
configured `connect_timeout`).
|
||||||
|
:return: The base64-encoded response received from the device.
|
||||||
|
"""
|
||||||
|
loop = get_or_create_event_loop()
|
||||||
|
data = loop.run_until_complete(
|
||||||
|
self._read(device, service_uuid, interface, connect_timeout)
|
||||||
|
)
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
@action
|
||||||
|
def write(
|
||||||
|
self,
|
||||||
|
device: str,
|
||||||
|
data: Union[str, bytes],
|
||||||
|
service_uuid: UUIDType,
|
||||||
|
interface: Optional[str] = None,
|
||||||
|
connect_timeout: Optional[float] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Writes data to a device
|
Writes data to a device
|
||||||
|
|
||||||
:param device: Device address to connect to
|
:param device: Name or address of the device to read from.
|
||||||
:param data: Data to be written (str or bytes)
|
:param data: Data to be written, either as bytes or as a base64-encoded string.
|
||||||
|
:param service_uuid: Service UUID.
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
:param interface: Bluetooth adapter name to use (default configured if None)
|
||||||
:param handle: Device handle. Either the UUID or the device handle must be specified
|
:param connect_timeout: Connection timeout in seconds (default: same as the
|
||||||
:param binary: Set to true if data is a base64-encoded binary string
|
configured `connect_timeout`).
|
||||||
:param disconnect_on_recv: If True (default) disconnect when the response is received
|
|
||||||
:param kwargs: Extra arguments to be passed to :meth:`connect`
|
|
||||||
"""
|
"""
|
||||||
if interface is None:
|
loop = get_or_create_event_loop()
|
||||||
interface = self.interface
|
if isinstance(data, str):
|
||||||
if binary:
|
data = base64.b64decode(data.encode())
|
||||||
data = base64.decodebytes(data.encode() if isinstance(data, str) else data)
|
|
||||||
|
|
||||||
self.connect(device, interface=interface, **kwargs)
|
loop.run_until_complete(
|
||||||
req = self._req_by_addr[device]
|
self._write(device, data, service_uuid, interface, connect_timeout)
|
||||||
|
)
|
||||||
data = req.write_by_handle(handle, data)[0]
|
|
||||||
|
|
||||||
if binary:
|
|
||||||
data = base64.encodebytes(data.encode() if isinstance(data, str) else data).decode().strip()
|
|
||||||
if disconnect_on_recv:
|
|
||||||
self.disconnect(device)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
def disconnect(self, device: str):
|
def status(self, *_, **__) -> Collection[Entity]:
|
||||||
"""
|
"""
|
||||||
Disconnect from a connected device
|
Alias for :meth:`.scan`.
|
||||||
|
|
||||||
:param device: Device address
|
|
||||||
"""
|
"""
|
||||||
req = self._req_by_addr.get(device)
|
return self.scan().output
|
||||||
if not req:
|
|
||||||
self.logger.info('Device {} not connected'.format(device))
|
|
||||||
|
|
||||||
req.disconnect()
|
@override
|
||||||
self.logger.info('Device {} disconnected'.format(device))
|
def transform_entities(self, entities: Collection[BLEDevice]) -> Collection[Device]:
|
||||||
|
return [
|
||||||
|
Device(
|
||||||
|
id=dev.address,
|
||||||
|
name=self._get_device_name(dev),
|
||||||
|
)
|
||||||
|
for dev in entities
|
||||||
|
]
|
||||||
|
|
||||||
@action
|
@override
|
||||||
def discover_primary(self, device: str, interface: str = None, **kwargs) -> BluetoothDiscoverPrimaryResponse:
|
async def listen(self):
|
||||||
"""
|
device_addresses = set()
|
||||||
Discover the primary services advertised by a LE bluetooth device
|
|
||||||
|
|
||||||
:param device: Device address to connect to
|
while True:
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
entities = await self._scan()
|
||||||
:param kwargs: Extra arguments to be passed to :meth:`connect`
|
new_device_addresses = {e.id for e in entities}
|
||||||
"""
|
missing_device_addresses = device_addresses - new_device_addresses
|
||||||
if interface is None:
|
missing_devices = [
|
||||||
interface = self.interface
|
dev
|
||||||
|
for addr, dev in self._devices.items()
|
||||||
|
if addr in missing_device_addresses
|
||||||
|
]
|
||||||
|
|
||||||
self.connect(device, interface=interface, **kwargs)
|
for dev in missing_devices:
|
||||||
req = self._req_by_addr[device]
|
self._post_event(BluetoothDeviceLostEvent, dev)
|
||||||
services = req.discover_primary()
|
self._devices.pop(dev.address, None)
|
||||||
self.disconnect(device)
|
|
||||||
return BluetoothDiscoverPrimaryResponse(services=services)
|
|
||||||
|
|
||||||
@action
|
device_addresses = new_device_addresses
|
||||||
def discover_characteristics(self, device: str, interface: str = None, **kwargs) \
|
|
||||||
-> BluetoothDiscoverCharacteristicsResponse:
|
|
||||||
"""
|
|
||||||
Discover the characteristics of a LE bluetooth device
|
|
||||||
|
|
||||||
:param device: Device address to connect to
|
|
||||||
:param interface: Bluetooth adapter name to use (default configured if None)
|
|
||||||
:param kwargs: Extra arguments to be passed to :meth:`connect`
|
|
||||||
"""
|
|
||||||
if interface is None:
|
|
||||||
interface = self.interface
|
|
||||||
|
|
||||||
self.connect(device, interface=interface, **kwargs)
|
|
||||||
req = self._req_by_addr[device]
|
|
||||||
characteristics = req.discover_characteristics()
|
|
||||||
self.disconnect(device)
|
|
||||||
return BluetoothDiscoverCharacteristicsResponse(characteristics=characteristics)
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -2,7 +2,6 @@ manifest:
|
||||||
events: {}
|
events: {}
|
||||||
install:
|
install:
|
||||||
pip:
|
pip:
|
||||||
- pybluez
|
- bleak
|
||||||
- gattlib
|
|
||||||
package: platypush.plugins.bluetooth.ble
|
package: platypush.plugins.bluetooth.ble
|
||||||
type: plugin
|
type: plugin
|
||||||
|
|
|
@ -1,19 +1,29 @@
|
||||||
import enum
|
import enum
|
||||||
|
from typing import Collection
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from threading import RLock
|
|
||||||
from typing import Collection, Dict, Optional, Union
|
|
||||||
|
|
||||||
from bleak import BleakClient, BleakScanner
|
|
||||||
from bleak.backends.device import BLEDevice
|
from bleak.backends.device import BLEDevice
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
from platypush.context import get_or_create_event_loop
|
from platypush.context import get_or_create_event_loop
|
||||||
from platypush.entities import Entity, EnumSwitchEntityManager
|
from platypush.entities import EnumSwitchEntityManager
|
||||||
from platypush.entities.switches import EnumSwitch
|
from platypush.entities.switches import EnumSwitch
|
||||||
from platypush.plugins import AsyncRunnablePlugin, action
|
from platypush.plugins import action
|
||||||
|
from platypush.plugins.bluetooth.ble import BluetoothBlePlugin, UUIDType
|
||||||
|
|
||||||
|
|
||||||
|
class Command(enum.Enum):
|
||||||
|
"""
|
||||||
|
Supported commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PRESS = b'\x57\x01\x00'
|
||||||
|
ON = b'\x57\x01\x01'
|
||||||
|
OFF = b'\x57\x01\x02'
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
|
class SwitchbotBluetoothPlugin(BluetoothBlePlugin, EnumSwitchEntityManager):
|
||||||
"""
|
"""
|
||||||
Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
|
Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
|
||||||
programmatically control switches over a Bluetooth interface.
|
programmatically control switches over a Bluetooth interface.
|
||||||
|
@ -29,97 +39,30 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Bluetooth UUID prefixes exposed by SwitchBot devices
|
# Map of service names -> UUID prefixes exposed by SwitchBot devices
|
||||||
_uuid_prefixes = {
|
_uuid_prefixes = {
|
||||||
'tx': '002',
|
'tx': '002',
|
||||||
'rx': '003',
|
'rx': '003',
|
||||||
'service': 'd00',
|
'service': 'd00',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static list of Bluetooth UUIDs commonly exposed by SwitchBot devices.
|
# Static list of Bluetooth service UUIDs commonly exposed by SwitchBot
|
||||||
|
# devices.
|
||||||
_uuids = {
|
_uuids = {
|
||||||
service: UUID(f'cba20{prefix}-224d-11e6-9fb8-0002a5d5c51b')
|
service: UUID(f'cba20{prefix}-224d-11e6-9fb8-0002a5d5c51b')
|
||||||
for service, prefix in _uuid_prefixes.items()
|
for service, prefix in _uuid_prefixes.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
class Command(enum.Enum):
|
def __init__(self, *args, **kwargs):
|
||||||
"""
|
super().__init__(*args, service_uuids=self._uuids.values(), **kwargs)
|
||||||
Supported commands.
|
|
||||||
"""
|
|
||||||
|
|
||||||
PRESS = b'\x57\x01\x00'
|
|
||||||
ON = b'\x57\x01\x01'
|
|
||||||
OFF = b'\x57\x01\x02'
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
interface: Optional[str] = None,
|
|
||||||
connect_timeout: Optional[float] = 5,
|
|
||||||
device_names: Optional[Dict[str, str]] = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
:param interface: Name of the Bluetooth interface to use (default: first available).
|
|
||||||
:param connect_timeout: Timeout in seconds for the connection to the
|
|
||||||
Switchbot device. Default: 5 seconds
|
|
||||||
:param device_names: Bluetooth address -> device name mapping. If not
|
|
||||||
specified, the device's address will be used as a name as well.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
.. code-block:: json
|
|
||||||
|
|
||||||
{
|
|
||||||
'00:11:22:33:44:55': 'My Switchbot',
|
|
||||||
'00:11:22:33:44:56': 'My Switchbot 2',
|
|
||||||
'00:11:22:33:44:57': 'My Switchbot 3'
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
self._interface = interface
|
|
||||||
self._connect_timeout = connect_timeout if connect_timeout else 5
|
|
||||||
self._scan_lock = RLock()
|
|
||||||
self._devices: Dict[str, BLEDevice] = {}
|
|
||||||
self._device_name_by_addr = device_names or {}
|
|
||||||
self._device_addr_by_name = {
|
|
||||||
name: addr for addr, name in self._device_name_by_addr.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _run(
|
async def _run(
|
||||||
self, device: str, command: Command, uuid: Union[UUID, str] = _uuids['tx']
|
self,
|
||||||
|
device: str,
|
||||||
|
command: Command,
|
||||||
|
service_uuid: UUIDType = _uuids['tx'],
|
||||||
):
|
):
|
||||||
"""
|
await self._write(device, command.value, service_uuid)
|
||||||
Run a command on a Switchbot device.
|
|
||||||
|
|
||||||
:param device: Device name or address.
|
|
||||||
:param command: Command to run.
|
|
||||||
:param uuid: On which UUID the command should be sent. Default: the
|
|
||||||
Switchbot registered ``tx`` service.
|
|
||||||
"""
|
|
||||||
dev = await self._get_device(device)
|
|
||||||
async with BleakClient(
|
|
||||||
dev.address, adapter=self._interface, timeout=self._connect_timeout
|
|
||||||
) as client:
|
|
||||||
await client.write_gatt_char(str(uuid), command.value)
|
|
||||||
|
|
||||||
async def _get_device(self, device: str) -> BLEDevice:
|
|
||||||
"""
|
|
||||||
Utility method to get a device by name or address.
|
|
||||||
"""
|
|
||||||
addr = (
|
|
||||||
self._device_addr_by_name[device]
|
|
||||||
if device in self._device_addr_by_name
|
|
||||||
else device
|
|
||||||
)
|
|
||||||
|
|
||||||
if addr not in self._devices:
|
|
||||||
self.logger.info('Scanning for unknown device "%s"', device)
|
|
||||||
await self._scan()
|
|
||||||
|
|
||||||
dev = self._devices.get(addr)
|
|
||||||
assert dev is not None, f'Unknown device: "{device}"'
|
|
||||||
return dev
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def press(self, device: str):
|
def press(self, device: str):
|
||||||
|
@ -129,7 +72,7 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
loop = get_or_create_event_loop()
|
loop = get_or_create_event_loop()
|
||||||
return loop.run_until_complete(self._run(device, self.Command.PRESS))
|
return loop.run_until_complete(self._run(device, Command.PRESS))
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def toggle(self, device, **_):
|
def toggle(self, device, **_):
|
||||||
|
@ -143,7 +86,7 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
loop = get_or_create_event_loop()
|
loop = get_or_create_event_loop()
|
||||||
return loop.run_until_complete(self._run(device, self.Command.ON))
|
return loop.run_until_complete(self._run(device, Command.ON))
|
||||||
|
|
||||||
@action
|
@action
|
||||||
def off(self, device: str, **_):
|
def off(self, device: str, **_):
|
||||||
|
@ -153,11 +96,11 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
|
||||||
:param device: Device name or address
|
:param device: Device name or address
|
||||||
"""
|
"""
|
||||||
loop = get_or_create_event_loop()
|
loop = get_or_create_event_loop()
|
||||||
return loop.run_until_complete(self._run(device, self.Command.OFF))
|
return loop.run_until_complete(self._run(device, Command.OFF))
|
||||||
|
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
# pylint: disable=arguments-differ
|
def set_value(self, device: str, *_, data: str, **__):
|
||||||
def set_value(self, device: str, data: str, *_, **__):
|
|
||||||
"""
|
"""
|
||||||
Entity-compatible ``set_value`` method to send a command to a device.
|
Entity-compatible ``set_value`` method to send a command to a device.
|
||||||
|
|
||||||
|
@ -170,76 +113,29 @@ class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if data == 'on':
|
if data == 'on':
|
||||||
return self.on(device)
|
self.on(device)
|
||||||
if data == 'off':
|
if data == 'off':
|
||||||
return self.off(device)
|
self.off(device)
|
||||||
if data == 'press':
|
if data == 'press':
|
||||||
return self.press(device)
|
self.press(device)
|
||||||
|
|
||||||
self.logger.warning('Unknown command for SwitchBot "%s": "%s"', device, data)
|
self.logger.warning('Unknown command for SwitchBot "%s": "%s"', device, data)
|
||||||
return None
|
|
||||||
|
|
||||||
@action
|
|
||||||
def scan(self, duration: Optional[float] = None) -> Collection[Entity]:
|
|
||||||
"""
|
|
||||||
Scan for available Switchbot devices nearby.
|
|
||||||
|
|
||||||
:param duration: Scan duration in seconds (default: same as the plugin's
|
|
||||||
`poll_interval` configuration parameter)
|
|
||||||
:return: The list of discovered Switchbot devices.
|
|
||||||
"""
|
|
||||||
loop = get_or_create_event_loop()
|
|
||||||
return loop.run_until_complete(self._scan(duration))
|
|
||||||
|
|
||||||
@action
|
|
||||||
def status(self, *_, **__) -> Collection[Entity]:
|
|
||||||
"""
|
|
||||||
Alias for :meth:`.scan`.
|
|
||||||
"""
|
|
||||||
return self.scan().output
|
|
||||||
|
|
||||||
async def _scan(self, duration: Optional[float] = None) -> Collection[Entity]:
|
|
||||||
with self._scan_lock:
|
|
||||||
timeout = duration or self.poll_interval or 5
|
|
||||||
devices = await BleakScanner.discover(
|
|
||||||
adapter=self._interface, timeout=timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
compatible_devices = [
|
|
||||||
d
|
|
||||||
for d in devices
|
|
||||||
if set(d.metadata.get('uuids', [])).intersection(
|
|
||||||
map(str, self._uuids.values())
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
new_devices = [
|
|
||||||
dev for dev in compatible_devices if dev.address not in self._devices
|
|
||||||
]
|
|
||||||
|
|
||||||
self._devices.update({dev.address: dev for dev in compatible_devices})
|
|
||||||
|
|
||||||
entities = self.transform_entities(compatible_devices)
|
|
||||||
self.publish_entities(new_devices)
|
|
||||||
return entities
|
|
||||||
|
|
||||||
|
@override
|
||||||
def transform_entities(
|
def transform_entities(
|
||||||
self, entities: Collection[BLEDevice]
|
self, entities: Collection[BLEDevice]
|
||||||
) -> Collection[EnumSwitch]:
|
) -> Collection[EnumSwitch]:
|
||||||
|
devices = super().transform_entities(entities)
|
||||||
return [
|
return [
|
||||||
EnumSwitch(
|
EnumSwitch(
|
||||||
id=dev.address,
|
id=dev.id,
|
||||||
name=self._device_name_by_addr.get(dev.address, dev.name),
|
name=dev.name,
|
||||||
value='on',
|
value=None,
|
||||||
values=['on', 'off', 'press'],
|
values=['on', 'off', 'press'],
|
||||||
is_write_only=True,
|
is_write_only=True,
|
||||||
)
|
)
|
||||||
for dev in entities
|
for dev in devices
|
||||||
]
|
]
|
||||||
|
|
||||||
async def listen(self):
|
|
||||||
while True:
|
|
||||||
await self._scan()
|
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
Loading…
Reference in a new issue