Migrated/refactored switchbot.bluetooth integration.

- Out `gattlib` + `pybluez`, in `bleak`. It's not platform-dependent, it doesn't
  require libboost and other heavy build dependencies, and it doesn't require the
  user that runs the service from having special privileges to access raw
  Bluetooth sockets.

- Better integration with Platypush native entities. The devices are now mapped
  to write-only `EnumSwitch` entities, and the status returns the serialized
  representation of those entities instead of the previous intermediate
  representation.
This commit is contained in:
Fabio Manganiello 2023-02-08 22:42:00 +01:00
parent 35719b0da9
commit 8469a1027f
Signed by: blacklight
GPG key ID: D90FBA7F76362774
4 changed files with 135 additions and 121 deletions

View file

@ -296,6 +296,7 @@ autodoc_mock_imports = [
'aiofiles', 'aiofiles',
'aiofiles.os', 'aiofiles.os',
'async_lru', 'async_lru',
'bleak',
] ]
sys.path.insert(0, os.path.abspath('../..')) sys.path.insert(0, os.path.abspath('../..'))

View file

@ -1,131 +1,154 @@
import enum import enum
import time from uuid import UUID
from typing import Any, Collection, Dict, List, Optional from threading import RLock
from typing import Collection, Dict, Optional, Union
from platypush.entities import EnumSwitchEntityManager from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice
from platypush.context import get_or_create_event_loop
from platypush.entities import Entity, EnumSwitchEntityManager
from platypush.entities.switches import EnumSwitch from platypush.entities.switches import EnumSwitch
from platypush.message.response.bluetooth import BluetoothScanResponse from platypush.plugins import AsyncRunnablePlugin, action
from platypush.plugins import action
from platypush.plugins.bluetooth.ble import BluetoothBlePlugin
# pylint: disable=too-many-ancestors # pylint: disable=too-many-ancestors
class SwitchbotBluetoothPlugin(BluetoothBlePlugin, EnumSwitchEntityManager): class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, 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.
See :class:`platypush.plugins.bluetooth.ble.BluetoothBlePlugin` for how to enable BLE permissions for Note that this plugin currently only supports Switchbot "bot" devices
the platypush user (a simple solution may be to run it as root, but that's usually NOT a good idea). (mechanical switch pressers). For support for other devices, you may want
the :class:`platypush.plugins.switchbot.SwitchbotPlugin` integration
(which requires a Switchbot hub).
Requires: Requires:
* **pybluez** (``pip install pybluez``) * **bleak** (``pip install bleak``)
* **gattlib** (``pip install gattlib``)
* **libboost** (on Debian ```apt-get install libboost-python-dev libboost-thread-dev``)
""" """
uuid = 'cba20002-224d-11e6-9fb8-0002a5d5c51b' # Bluetooth UUID prefixes exposed by SwitchBot devices
handle = 0x16 _uuid_prefixes = {
'tx': '002',
'rx': '003',
'service': 'd00',
}
# Static list of Bluetooth 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): class Command(enum.Enum):
""" """
Base64 encoded commands Supported commands.
""" """
# \x57\x01\x00 PRESS = b'\x57\x01\x00'
PRESS = 'VwEA' ON = b'\x57\x01\x01'
# # \x57\x01\x01 OFF = b'\x57\x01\x02'
ON = 'VwEB'
# # \x57\x01\x02
OFF = 'VwEC'
def __init__( def __init__(
self, self,
interface=None, connect_timeout: Optional[float] = 5,
connect_timeout=None, device_names: Optional[Dict[str, str]] = None,
scan_timeout=2, **kwargs,
devices=None,
**kwargs
): ):
""" """
:param interface: Bluetooth interface to use (e.g. hci0) default: first available one :param connect_timeout: Timeout in seconds for the connection to the
:type interface: str 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.
:param connect_timeout: Timeout for the connection to the Switchbot device - default: None Example:
:type connect_timeout: float .. code-block:: json
:param scan_timeout: Timeout for the scan operations {
:type scan_timeout: float '00:11:22:33:44:55': 'My Switchbot',
'00:11:22:33:44:56': 'My Switchbot 2',
:param devices: Devices to control, as a MAC address -> name map '00:11:22:33:44:57': 'My Switchbot 3'
:type devices: dict
"""
super().__init__(interface=interface, **kwargs)
self.connect_timeout = connect_timeout if connect_timeout else 5
self.scan_timeout = scan_timeout if scan_timeout else 2
self.configured_devices = devices or {}
self.configured_devices_by_name = {
name: addr for addr, name in self.configured_devices.items()
} }
def _run(self, device: str, command: Command): """
device = self.configured_devices_by_name.get(device, '') super().__init__(**kwargs)
n_tries = 1
try: self._connect_timeout = connect_timeout if connect_timeout else 5
self.write( self._scan_lock = RLock()
device, self._devices: Dict[str, BLEDevice] = {}
command.value, self._device_name_by_addr = device_names or {}
handle=self.handle, self._device_addr_by_name = {
channel_type='random', name: addr for addr, name in self._device_name_by_addr.items()
binary=True, }
async def _run(
self, device: str, command: Command, uuid: Union[UUID, str] = _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, 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
) )
except Exception as e:
self.logger.exception(e)
n_tries -= 1
if n_tries == 0: if addr not in self._devices:
raise e self.logger.info('Scanning for unknown device "%s"', device)
time.sleep(5) await self._scan()
return self.status(device) dev = self._devices.get(addr)
assert dev is not None, f'Unknown device: "{device}"'
return dev
@action @action
def press(self, device): def press(self, device: str):
""" """
Send a press button command to a device Send a press button command to a device
:param device: Device name or address :param device: Device name or address
:type device: str
""" """
return self._run(device, self.Command.PRESS) loop = get_or_create_event_loop()
return loop.run_until_complete(self._run(device, self.Command.PRESS))
@action @action
def toggle(self, device, **_): def toggle(self, device, **_):
return self.press(device) return self.press(device)
@action @action
def on(self, device, **_): def on(self, device: str, **_):
""" """
Send a press-on button command to a device Send a press-on button command to a device
:param device: Device name or address :param device: Device name or address
:type device: str
""" """
return self._run(device, self.Command.ON) loop = get_or_create_event_loop()
return loop.run_until_complete(self._run(device, self.Command.ON))
@action @action
def off(self, device, **_): def off(self, device: str, **_):
""" """
Send a press-off button command to a device Send a press-off button command to a device
:param device: Device name or address :param device: Device name or address
:type device: str
""" """
return self._run(device, self.Command.OFF) loop = get_or_create_event_loop()
return loop.run_until_complete(self._run(device, self.Command.OFF))
@action @action
# pylint: disable=arguments-differ # pylint: disable=arguments-differ
@ -152,66 +175,63 @@ class SwitchbotBluetoothPlugin(BluetoothBlePlugin, EnumSwitchEntityManager):
return None return None
@action @action
def scan( def scan(self, duration: Optional[float] = None) -> Collection[Entity]:
self, interface: Optional[str] = None, duration: int = 10
) -> BluetoothScanResponse:
""" """
Scan for available Switchbot devices nearby. Scan for available Switchbot devices nearby.
:param interface: Bluetooth interface to scan (default: default configured interface) :param duration: Scan duration in seconds (default: same as the plugin's
:param duration: Scan duration in seconds `poll_interval` configuration parameter)
:return: The list of discovered Switchbot devices.
""" """
loop = get_or_create_event_loop()
compatible_devices: Dict[str, Any] = {} return loop.run_until_complete(self._scan(duration))
devices = (
super().scan(interface=interface, duration=duration).devices # type: ignore
)
for dev in devices:
try:
characteristics = [
chrc
for chrc in self.discover_characteristics( # type: ignore
dev['addr'],
channel_type='random',
wait=False,
timeout=self.scan_timeout,
).characteristics
if chrc.get('uuid') == self.uuid
]
if characteristics:
compatible_devices[dev['addr']] = None
except Exception as e:
self.logger.warning('Device scan error: %s', e)
self.publish_entities(compatible_devices)
return BluetoothScanResponse(devices=compatible_devices)
@action @action
def status(self, *_, **__) -> List[dict]: def status(self, *_, **__) -> Collection[Entity]:
self.publish_entities(self.configured_devices) """
return [ Alias for :meth:`.scan`.
{ """
'address': addr, return self.scan().output
'id': addr,
'name': name, async def _scan(self, duration: Optional[float] = None) -> Collection[Entity]:
'on': False, with self._scan_lock:
} timeout = duration or self.poll_interval or 5
for addr, name in self.configured_devices.items() devices = await BleakScanner.discover(timeout=timeout)
compatible_devices = [
d
for d in devices
if set(d.metadata.get('uuids', [])).intersection(
map(str, self._uuids.values())
)
] ]
def transform_entities(self, entities: Collection[dict]) -> Collection[EnumSwitch]: 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
def transform_entities(
self, entities: Collection[BLEDevice]
) -> Collection[EnumSwitch]:
return [ return [
EnumSwitch( EnumSwitch(
id=addr, id=dev.address,
name=name, name=self._device_name_by_addr.get(dev.address, dev.name),
value='on', value='on',
values=['on', 'off', 'press'], values=['on', 'off', 'press'],
is_write_only=True, is_write_only=True,
) )
for addr, name in entities for dev in entities
] ]
async def listen(self):
while True:
await self._scan()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -2,12 +2,6 @@ manifest:
events: {} events: {}
install: install:
pip: pip:
- pybluez - bleak
- gattlib
apt:
- libboost-python-dev
- libboost-thread-dev
pacman:
- boost-libs
package: platypush.plugins.switchbot.bluetooth package: platypush.plugins.switchbot.bluetooth
type: plugin type: plugin

View file

@ -175,8 +175,7 @@ setup(
'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'], 'alexa': ['avs @ https://github.com/BlackLight/avs/tarball/master'],
# Support for bluetooth devices # Support for bluetooth devices
'bluetooth': [ 'bluetooth': [
'pybluez', 'bleak',
'gattlib',
'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master', 'pyobex @ https://github.com/BlackLight/PyOBEX/tarball/master',
], ],
# Support for TP-Link devices # Support for TP-Link devices