From 664ce4050d1821f581ef91fd85031018c7754025 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 10 May 2021 18:43:00 +0200 Subject: [PATCH] Added Switchbot plugin --- CHANGELOG.md | 16 ++ .../platypush/plugins/switch.switchbot.rst | 6 - .../platypush/plugins/switchbot.bluetooth.rst | 5 + docs/source/platypush/plugins/switchbot.rst | 5 + docs/source/platypush/plugins/zwave._base.rst | 5 + docs/source/plugins.rst | 4 +- .../src/components/panels/Switches/Switchbot | 1 + .../Index.vue | 2 +- platypush/plugins/switch/__init__.py | 42 +-- platypush/plugins/switchbot/__init__.py | 264 ++++++++++++++++++ .../__init__.py => switchbot/bluetooth.py} | 5 +- platypush/plugins/zwave/__init__.py | 1 + platypush/schemas/switch.py | 8 + platypush/schemas/switchbot.py | 80 ++++++ 14 files changed, 407 insertions(+), 37 deletions(-) delete mode 100644 docs/source/platypush/plugins/switch.switchbot.rst create mode 100644 docs/source/platypush/plugins/switchbot.bluetooth.rst create mode 100644 docs/source/platypush/plugins/switchbot.rst create mode 100644 docs/source/platypush/plugins/zwave._base.rst create mode 120000 platypush/backend/http/webapp/src/components/panels/Switches/Switchbot rename platypush/backend/http/webapp/src/components/panels/Switches/{SwitchSwitchbot => SwitchbotBluetooth}/Index.vue (97%) create mode 100644 platypush/plugins/switchbot/__init__.py rename platypush/plugins/{switch/switchbot/__init__.py => switchbot/bluetooth.py} (97%) create mode 100644 platypush/schemas/switch.py create mode 100644 platypush/schemas/switchbot.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad5307d04..85e5b78295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. Given the high speed of development in the first phase, changes are being reported only starting from v0.20.2. +## [Unreleased] + +### Added + +- Added `switchbot` plugin to interact with Switchbot devices over the cloud API instead of + directly accessing the device's Bluetooth interface. + +- Added `marshmallow` dependency - it will be used from now own to dump and document schemas + and responses instead of the currently mixed approach with `Response` objects and plain + dictionaries and lists. + +### Changed + +- `switch.switchbot` plugin renamed to `switchbot.bluetooth` plugin, while the new plugin + that uses the Switchbot API is simply named `switchbot`. + ## [0.21.0] - 2021-05-06 ### Added diff --git a/docs/source/platypush/plugins/switch.switchbot.rst b/docs/source/platypush/plugins/switch.switchbot.rst deleted file mode 100644 index f434e8ebf8..0000000000 --- a/docs/source/platypush/plugins/switch.switchbot.rst +++ /dev/null @@ -1,6 +0,0 @@ -``platypush.plugins.switch.switchbot`` -====================================== - -.. automodule:: platypush.plugins.switch.switchbot - :members: - diff --git a/docs/source/platypush/plugins/switchbot.bluetooth.rst b/docs/source/platypush/plugins/switchbot.bluetooth.rst new file mode 100644 index 0000000000..26d2c95416 --- /dev/null +++ b/docs/source/platypush/plugins/switchbot.bluetooth.rst @@ -0,0 +1,5 @@ +``platypush.plugins.switchbot.bluetooth`` +========================================= + +.. automodule:: platypush.plugins.switchbot.bluetooth + :members: diff --git a/docs/source/platypush/plugins/switchbot.rst b/docs/source/platypush/plugins/switchbot.rst new file mode 100644 index 0000000000..a281a47230 --- /dev/null +++ b/docs/source/platypush/plugins/switchbot.rst @@ -0,0 +1,5 @@ +``platypush.plugins.switchbot`` +=============================== + +.. automodule:: platypush.plugins.switchbot + :members: diff --git a/docs/source/platypush/plugins/zwave._base.rst b/docs/source/platypush/plugins/zwave._base.rst new file mode 100644 index 0000000000..294cd16bc9 --- /dev/null +++ b/docs/source/platypush/plugins/zwave._base.rst @@ -0,0 +1,5 @@ +``platypush.plugins.zwave._base`` +================================= + +.. automodule:: platypush.plugins.zwave._base + :members: diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 8961722849..5af5c7d18e 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -118,9 +118,10 @@ Plugins platypush/plugins/stt.picovoice.hotword.rst platypush/plugins/stt.picovoice.speech.rst platypush/plugins/switch.rst - platypush/plugins/switch.switchbot.rst platypush/plugins/switch.tplink.rst platypush/plugins/switch.wemo.rst + platypush/plugins/switchbot.rst + platypush/plugins/switchbot.bluetooth.rst platypush/plugins/system.rst platypush/plugins/tcp.rst platypush/plugins/tensorflow.rst @@ -145,4 +146,5 @@ Plugins platypush/plugins/zeroconf.rst platypush/plugins/zigbee.mqtt.rst platypush/plugins/zwave.rst + platypush/plugins/zwave._base.rst platypush/plugins/zwave.mqtt.rst diff --git a/platypush/backend/http/webapp/src/components/panels/Switches/Switchbot b/platypush/backend/http/webapp/src/components/panels/Switches/Switchbot new file mode 120000 index 0000000000..b82b266551 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Switches/Switchbot @@ -0,0 +1 @@ +SwitchbotBluetooth \ No newline at end of file diff --git a/platypush/backend/http/webapp/src/components/panels/Switches/SwitchSwitchbot/Index.vue b/platypush/backend/http/webapp/src/components/panels/Switches/SwitchbotBluetooth/Index.vue similarity index 97% rename from platypush/backend/http/webapp/src/components/panels/Switches/SwitchSwitchbot/Index.vue rename to platypush/backend/http/webapp/src/components/panels/Switches/SwitchbotBluetooth/Index.vue index f4eea89ec0..69e497c3b8 100644 --- a/platypush/backend/http/webapp/src/components/panels/Switches/SwitchSwitchbot/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Switches/SwitchbotBluetooth/Index.vue @@ -35,7 +35,7 @@ import Switch from "@/components/panels/Switches/Switch"; import Modal from "@/components/Modal"; export default { - name: "SwitchSwitchbot", + name: "SwitchbotBluetooth", components: {Modal, Switch, Loading}, mixins: [SwitchMixin], } diff --git a/platypush/plugins/switch/__init__.py b/platypush/plugins/switch/__init__.py index e7f71c788d..aaef9440be 100644 --- a/platypush/plugins/switch/__init__.py +++ b/platypush/plugins/switch/__init__.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Union from platypush.plugins import Plugin, action @@ -27,46 +27,34 @@ class SwitchPlugin(Plugin): raise NotImplementedError() @action - def switch_status(self, device=None): - """ Get the status of a specified device or of all the configured devices (default)""" + def switch_status(self, device=None) -> Union[dict, List[dict]]: + """ + Get the status of a specified device or of all the configured devices (default). + + :param device: Filter by device name or ID. + :return: .. schema:: switch.SwitchStatusSchema(many=True) + """ devices = self.switches if device: devices = [d for d in self.switches if d.get('id') == device or d.get('name') == device] - if devices: - return devices[0] - else: - return None + return devices[0] if devices else [] return devices @action - def status(self, device=None, *args, **kwargs): + def status(self, device=None, *args, **kwargs) -> Union[dict, List[dict]]: """ - Status function - if not overridden it calls :meth:`.switch_status`. You may want to override it if your plugin - does not handle only switches. + Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`). + + :param device: Filter by device name or ID. + :return: .. schema:: switch.SwitchStatusSchema(many=True) """ return self.switch_status(device) @property def switches(self) -> List[dict]: """ - This property must be implemented by the derived classes and must return a dictionary in the following format: - - .. code-block:: json - - [ - { - "name": "switch_1", - "on": true - }, - { - "name": "switch_2", - "on": false - }, - ] - - ``name`` and ``on`` are the minimum set of attributes that should be returned for a switch, but more attributes - can also be added. + :return: .. schema:: switch.SwitchStatusSchema(many=True) """ raise NotImplementedError() diff --git a/platypush/plugins/switchbot/__init__.py b/platypush/plugins/switchbot/__init__.py new file mode 100644 index 0000000000..6c98db67a2 --- /dev/null +++ b/platypush/plugins/switchbot/__init__.py @@ -0,0 +1,264 @@ +import queue +import requests +import threading +from typing import List, Optional, Union + +from platypush.plugins import action +from platypush.plugins.switch import SwitchPlugin +from platypush.schemas.switchbot import DeviceSchema, DeviceStatusSchema, SceneSchema + + +class SwitchbotPlugin(SwitchPlugin): + """ + Plugin to interact with the devices registered to a Switchbot (https://www.switch-bot.com/) account/hub. + + The difference between this plugin and :class:`platypush.plugins.switchbot.bluetooth.SwitchbotBluetoothPlugin` is + that the latter acts like a Bluetooth hub/bridge that interacts directly with your Switchbot devices, while this + plugin requires the devices to be connected to a Switchbot Hub and it controls them through your cloud account. + + In order to use this plugin: + + - Set up a Switchbot Hub and configure your devices through the Switchbot app. + - Follow the steps on the `Switchbot API repo `_ + to get an API token from the app. + + """ + + def __init__(self, api_token: str, **kwargs): + """ + :param api_token: API token (see + `Getting started with the Switchbot API `_). + """ + super().__init__(**kwargs) + self._api_token = api_token + self._devices_by_id = {} + self._devices_by_name = {} + + @staticmethod + def _url_for(*args, device=None): + url = 'https://api.switch-bot.com/v1.0/' + if device: + url += f'devices/{device["id"]}/' + url += '/'.join(args) + return url + + def _run(self, method: str = 'get', *args, device=None, **kwargs): + response = getattr(requests, method)(self._url_for(*args, device=device), headers={ + 'Authorization': self._api_token, + 'Accept': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', + }, **kwargs) + + response.raise_for_status() + response = response.json() + assert response.get('statusCode') == 100, \ + f'Switchbot API request failed: {response.get("statusCode")}: {response.get("message")}' + + return response.get('body') + + def _get_device(self, device: str, use_cache=True): + if not use_cache: + self.devices() + + if device in self._devices_by_id: + return self._devices_by_id[device] + if device in self._devices_by_name: + return self._devices_by_name[device] + + assert use_cache, f'Device not found: {device}' + return self._get_device(device, use_cache=False) + + @action + def devices(self) -> List[dict]: + """ + Get the list of devices associated to the specified Switchbot API account. + + :return: .. schema:: switchbot.DeviceSchema(many=True) + """ + devices = DeviceSchema().dump(self._run('get', 'devices').get('deviceList', []), many=True) + for device in devices: + self._devices_by_id[device['id']] = device + self._devices_by_name[device['name']] = device + + return devices + + def _worker(self, q: queue.Queue, method: str = 'get', *args, device=None, **kwargs): + try: + res = self._run(method, *args, device=device, **kwargs) + q.put(DeviceStatusSchema().dump(res)) + except Exception as e: + self.logger.exception(e) + q.put(e) + + @action + def status(self, device: Optional[str] = None) -> Union[dict, List[dict]]: + """ + Get the status of all the registered devices or of a specific device. + + :param device: Filter by device ID or name. + :return: .. schema:: switchbot.DeviceStatusSchema(many=True) + """ + # noinspection PyUnresolvedReferences + devices = self.devices().output + if device: + return { + **device, + **self._run('get', 'status', device=self._get_device(device)), + } + + devices_by_id = {dev['id']: dev for dev in devices} + queues = [queue.Queue()] * len(devices) + workers = [ + threading.Thread(target=self._worker, args=(queues[i], 'get', 'status'), kwargs={'device': dev}) + for i, dev in enumerate(devices) + ] + + results = [] + for worker in workers: + worker.start() + + for q in queues: + response = q.get() + if not response: + continue + + assert not isinstance(response, Exception), str(response) + results.append({ + **devices_by_id.get(response.get('id'), {}), + **response, + }) + for worker in workers: + worker.join() + + return results + + @action + def press(self, device: str): + """ + Send a press-button command to a device. + + :param device: Device name or ID. + """ + device = self._get_device(device) + return self._run('post', 'commands', device=device, json={'command': 'press'}) + + @action + def toggle(self, device: str, **kwargs): + """ + Shortcut for :meth:`.press`. + + :param device: Device name or ID. + """ + return self.press(device) + + @action + def on(self, device: str, **kwargs): + """ + Send a turn-on command to a device + + :param device: Device name or ID. + """ + device = self._get_device(device) + return self._run('post', 'commands', device=device, json={'command': 'turnOn'}) + + @action + def off(self, device: str, **kwargs): + """ + Send a turn-off command to a device + + :param device: Device name or ID. + """ + device = self._get_device(device) + return self._run('post', 'commands', device=device, json={'command': 'turnOff'}) + + @property + def switches(self) -> List[dict]: + # noinspection PyUnresolvedReferences + return [ + dev for dev in self.status().output if 'on' in dev + ] + + @action + def set_curtain_position(self, device: str, position: int): + """ + Set the position of a curtain device. + + :param device: Device name or ID. + :param position: An integer between 0 (open) and 100 (closed). + """ + device = self._get_device(device) + return self._run('post', 'commands', device=device, json={ + 'command': 'setPosition', + 'commandType': 'command', + 'parameter': f'0,ff,{position}', + }) + + @action + def set_humidifier_efficiency(self, device: str, efficiency: Union[int, str]): + """ + Set the nebulization efficiency of a humidifier device. + + :param device: Device name or ID. + :param efficiency: An integer between 0 (open) and 100 (closed) or `auto`. + """ + device = self._get_device(device) + return self._run('post', 'commands', device=device, json={ + 'command': 'setMode', + 'commandType': 'command', + 'parameter': efficiency, + }) + + @action + def set_fan(self, device: str, speed: Optional[int] = None, swing_range: Optional[int] = None, + mode: Optional[int] = None): + """ + Set properties of a smart fan device. + + :param device: Device name or ID. + :param speed: Speed between 1 and 4. + :param swing_range: Swing range angle, between 0 and 120. + :param mode: Fan mode (1 or 2). + """ + # noinspection PyUnresolvedReferences + status = self.status(device=device).output + + if speed is None: + speed = status.get('speed') + if mode is None: + mode = status.get('mode') + if swing_range is None: + swing_range = status.get('swing_range') + + return self._run('post', 'commands', device=device, json={ + 'command': 'set', + 'commandType': 'command', + 'parameter': ','.join(['on', str(mode), str(speed), str(swing_range)]), + }) + + @action + def scenes(self) -> List[dict]: + """ + Get the list of registered scenes. + + :return: .. schema:: switchbot.SceneSchema(many=True) + """ + return SceneSchema().dump(self._run('get', 'scenes'), many=True) + + @action + def run_scene(self, scene: str): + """ + Execute a scene. + + :param scene: Scene ID or name. + """ + # noinspection PyUnresolvedReferences + scenes = [ + s for s in self.scenes().output + if s.get('id') == scene or s.get('name') == scene + ] + + assert scenes, f'No such scene: {scene}' + return self._run('post', 'scenes', scenes[0]['id'], 'execute') + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/switch/switchbot/__init__.py b/platypush/plugins/switchbot/bluetooth.py similarity index 97% rename from platypush/plugins/switch/switchbot/__init__.py rename to platypush/plugins/switchbot/bluetooth.py index dd2e27ac60..611450e6af 100644 --- a/platypush/plugins/switch/switchbot/__init__.py +++ b/platypush/plugins/switchbot/bluetooth.py @@ -8,10 +8,10 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin from platypush.plugins.switch import SwitchPlugin -class SwitchSwitchbotPlugin(SwitchPlugin, BluetoothBlePlugin): +class SwitchbotBluetoothPlugin(SwitchPlugin, BluetoothBlePlugin): """ Plugin to interact with a Switchbot (https://www.switch-bot.com/) device and - programmatically control buttons. + programmatically control switches over a Bluetooth interface. See :class:`platypush.plugins.bluetooth.ble.BluetoothBlePlugin` for how to enable BLE permissions for the platypush user (a simple solution may be to run it as root, but that's usually NOT a good idea). @@ -21,6 +21,7 @@ class SwitchSwitchbotPlugin(SwitchPlugin, BluetoothBlePlugin): * **pybluez** (``pip install pybluez``) * **gattlib** (``pip install gattlib``) * **libboost** (on Debian ```apt-get install libboost-python-dev libboost-thread-dev``) + """ uuid = 'cba20002-224d-11e6-9fb8-0002a5d5c51b' diff --git a/platypush/plugins/zwave/__init__.py b/platypush/plugins/zwave/__init__.py index 616ac411cf..9b836b4be3 100644 --- a/platypush/plugins/zwave/__init__.py +++ b/platypush/plugins/zwave/__init__.py @@ -53,6 +53,7 @@ class ZwavePlugin(ZwaveBasePlugin, SwitchPlugin): def status(self) -> Dict[str, Any]: """ Get the status of the controller. + :return: dict """ backend = self._get_backend() diff --git a/platypush/schemas/switch.py b/platypush/schemas/switch.py new file mode 100644 index 0000000000..f7ba47939d --- /dev/null +++ b/platypush/schemas/switch.py @@ -0,0 +1,8 @@ +from marshmallow import fields +from marshmallow.schema import Schema + + +class SwitchStatusSchema(Schema): + id = fields.Raw(metadata=dict(description='Device unique ID')) + name = fields.String(required=True, metadata=dict(description='Device name')) + on = fields.Boolean(required=True, metadata=dict(description='True if the device is on, False otherwise')) diff --git a/platypush/schemas/switchbot.py b/platypush/schemas/switchbot.py new file mode 100644 index 0000000000..971f6ff382 --- /dev/null +++ b/platypush/schemas/switchbot.py @@ -0,0 +1,80 @@ +from marshmallow import fields +from marshmallow.schema import Schema +from marshmallow.validate import OneOf + + +device_types = [ + 'Hub', + 'Hub Plus', + 'Hub Mini', + 'Bot', + 'Curtain', + 'Plug', + 'Meter', + 'Humidifier', + 'Smart Fan', + 'Air Conditioner', + 'TV', + 'Light', + 'IPTV / Streamer', + 'Set Top Box', + 'DVD', + 'Fan', + 'Projector', + 'Camera', + 'Air Purifier', + 'Speaker', + 'Water Heater', + 'Vacuum Cleaner', + 'Others', +] + + +class DeviceSchema(Schema): + id = fields.String(attribute='deviceId', required=True, metadata=dict(description='Device unique ID')) + name = fields.String(attribute='deviceName', metadata=dict(description='Device name')) + type = fields.String(attribute='deviceType', required=True, validate=OneOf(device_types), + metadata=dict(description=f'Supported types: [{", ".join(device_types)}]')) + hub_id = fields.String(attribute='hubDeviceId', metadata=dict(description='Parent hub device unique ID')) + cloud_service_enabled = fields.Boolean(attribute='enableCloudService', + metadata=dict(description='True if cloud access is enabled on this device,' + 'False otherwise. Only cloud-enabled devices can be ' + 'controlled from the switchbot plugin.')) + calibrated = fields.Boolean(attribute='calibrate', + metadata=dict(description='[Curtain devices only] Set to True if the device has ' + 'been calibrated, False otherwise')) + open_direction = fields.String(attribute='openDirection', + metadata=dict(description='[Curtain devices only] Direction where the curtains will ' + 'be opened ("left" or "right")')) + + +class DeviceStatusSchema(DeviceSchema): + on = fields.Boolean(attribute='power', metadata=dict(description='True if the device is on, False otherwise')) + moving = fields.Boolean(metadata=dict( + description='[Curtain devices only] True if the device is moving, False otherwise')) + position = fields.Int(attribute='slidePosition', metadata=dict( + description='[Curtain devices only] Position of the device on the curtain rail, between ' + '0 (open) and 1 (closed)')) + temperature = fields.Float(metadata=dict(description='[Meter/humidifier devices only] Temperature in Celsius')) + humidity = fields.Float(metadata=dict(description='[Meter/humidifier devices only] Humidity in %')) + nebulization_efficiency = fields.Float(attribute='nebulizationEfficiency', + metadata=dict(description='[Humidifier devices only] Nebulization ' + 'efficiency in %')) + auto = fields.Boolean(metadata=dict(description='[Humidifier devices only] True if auto mode is on')) + child_lock = fields.Boolean(attribute='childLock', + metadata=dict(description='[Humidifier devices only] True if safety lock is on')) + sound = fields.Boolean(metadata=dict(description='[Humidifier devices only] True if sound is muted')) + mode = fields.Int(metadata=dict(description='[Smart fan devices only] Fan mode')) + speed = fields.Float(metadata=dict(description='[Smart fan devices only] Fan speed, between 1 and 4')) + swinging = fields.Boolean(attribute='shaking', + metadata=dict(description='[Smart fan devices only] True if the device is swinging')) + swing_direction = fields.Int(attribute='shakeCenter', + metadata=dict(description='[Smart fan devices only] Swing direction')) + swing_range = fields.Float(attribute='shakeRange', + metadata=dict(description='[Smart fan devices only] Swing range angle, ' + 'between 0 and 120')) + + +class SceneSchema(Schema): + id = fields.String(attribute='sceneId', required=True, metadata=dict(description='Scene ID')) + name = fields.String(attribute='sceneName', metadata=dict(description='Scene name'))