platypush/platypush/plugins/switchbot/bluetooth/__init__.py

246 lines
7.6 KiB
Python
Raw Normal View History

import enum
from uuid import UUID
from threading import RLock
from typing import Collection, Dict, Optional, Union
2018-05-08 15:51:47 +02:00
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.plugins import AsyncRunnablePlugin, action
2018-05-08 15:51:47 +02:00
2019-07-02 12:02:28 +02:00
# pylint: disable=too-many-ancestors
class SwitchbotBluetoothPlugin(AsyncRunnablePlugin, EnumSwitchEntityManager):
2018-05-08 15:51:47 +02:00
"""
2018-06-25 19:57:43 +02:00
Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and
2021-05-10 18:43:00 +02:00
programmatically control switches over a Bluetooth interface.
2018-06-25 19:57:43 +02:00
Note that this plugin currently only supports Switchbot "bot" devices
(mechanical switch pressers). For support for other devices, you may want
the :class:`platypush.plugins.switchbot.SwitchbotPlugin` integration
(which requires a Switchbot hub).
2018-06-25 19:57:43 +02:00
Requires:
* **bleak** (``pip install bleak``)
2021-05-10 18:43:00 +02:00
2018-05-08 15:51:47 +02:00
"""
# Bluetooth UUID prefixes exposed by SwitchBot devices
_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):
"""
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,
):
2018-06-25 19:57:43 +02:00
"""
: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.
2018-06-25 19:57:43 +02:00
Example:
.. code-block:: json
2018-06-25 19:57:43 +02:00
{
'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'
}
2018-06-25 19:57:43 +02:00
"""
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()
2019-07-02 12:02:28 +02:00
}
2018-05-08 15:51:47 +02:00
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, 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
)
2019-07-02 12:02:28 +02:00
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
2018-05-08 15:51:47 +02:00
@action
def press(self, device: str):
2018-06-25 19:57:43 +02:00
"""
Send a press button command to a device
:param device: Device name or address
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(self._run(device, self.Command.PRESS))
2018-05-08 15:51:47 +02:00
@action
def toggle(self, device, **_):
2019-07-02 12:02:28 +02:00
return self.press(device)
@action
def on(self, device: str, **_):
2018-06-25 19:57:43 +02:00
"""
Send a press-on button command to a device
:param device: Device name or address
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(self._run(device, self.Command.ON))
2018-05-08 15:51:47 +02:00
@action
def off(self, device: str, **_):
2018-06-25 19:57:43 +02:00
"""
Send a press-off button command to a device
:param device: Device name or address
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(self._run(device, self.Command.OFF))
2018-05-08 15:51:47 +02:00
@action
# pylint: disable=arguments-differ
def set_value(self, device: str, data: str, *_, **__):
"""
Entity-compatible ``set_value`` method to send a command to a device.
:param device: Device name or address
:param data: Command to send. Possible values are:
- ``on``: Press the button and remain in the pressed state.
- ``off``: Release a previously pressed button.
- ``press``: Press and release the button.
"""
if data == 'on':
return self.on(device)
if data == 'off':
return self.off(device)
if data == 'press':
return 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]:
2019-07-02 12:02:28 +02:00
"""
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.
2019-07-02 12:02:28 +02:00
"""
loop = get_or_create_event_loop()
return loop.run_until_complete(self._scan(duration))
2018-05-08 15:51:47 +02:00
@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
2019-07-02 12:02:28 +02:00
]
2018-06-25 19:57:43 +02:00
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 [
EnumSwitch(
id=dev.address,
name=self._device_name_by_addr.get(dev.address, dev.name),
value='on',
values=['on', 'off', 'press'],
is_write_only=True,
)
for dev in entities
]
async def listen(self):
while True:
await self._scan()
2018-05-08 15:51:47 +02:00
2019-07-02 12:02:28 +02:00
# vim:sw=4:ts=4:et: