Added Switchbot plugin

This commit is contained in:
Fabio Manganiello 2021-05-10 18:43:00 +02:00
parent 69583d2e15
commit 664ce4050d
14 changed files with 407 additions and 37 deletions

View file

@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file. 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. 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 ## [0.21.0] - 2021-05-06
### Added ### Added

View file

@ -1,6 +0,0 @@
``platypush.plugins.switch.switchbot``
======================================
.. automodule:: platypush.plugins.switch.switchbot
:members:

View file

@ -0,0 +1,5 @@
``platypush.plugins.switchbot.bluetooth``
=========================================
.. automodule:: platypush.plugins.switchbot.bluetooth
:members:

View file

@ -0,0 +1,5 @@
``platypush.plugins.switchbot``
===============================
.. automodule:: platypush.plugins.switchbot
:members:

View file

@ -0,0 +1,5 @@
``platypush.plugins.zwave._base``
=================================
.. automodule:: platypush.plugins.zwave._base
:members:

View file

@ -118,9 +118,10 @@ Plugins
platypush/plugins/stt.picovoice.hotword.rst platypush/plugins/stt.picovoice.hotword.rst
platypush/plugins/stt.picovoice.speech.rst platypush/plugins/stt.picovoice.speech.rst
platypush/plugins/switch.rst platypush/plugins/switch.rst
platypush/plugins/switch.switchbot.rst
platypush/plugins/switch.tplink.rst platypush/plugins/switch.tplink.rst
platypush/plugins/switch.wemo.rst platypush/plugins/switch.wemo.rst
platypush/plugins/switchbot.rst
platypush/plugins/switchbot.bluetooth.rst
platypush/plugins/system.rst platypush/plugins/system.rst
platypush/plugins/tcp.rst platypush/plugins/tcp.rst
platypush/plugins/tensorflow.rst platypush/plugins/tensorflow.rst
@ -145,4 +146,5 @@ Plugins
platypush/plugins/zeroconf.rst platypush/plugins/zeroconf.rst
platypush/plugins/zigbee.mqtt.rst platypush/plugins/zigbee.mqtt.rst
platypush/plugins/zwave.rst platypush/plugins/zwave.rst
platypush/plugins/zwave._base.rst
platypush/plugins/zwave.mqtt.rst platypush/plugins/zwave.mqtt.rst

View file

@ -0,0 +1 @@
SwitchbotBluetooth

View file

@ -35,7 +35,7 @@ import Switch from "@/components/panels/Switches/Switch";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
export default { export default {
name: "SwitchSwitchbot", name: "SwitchbotBluetooth",
components: {Modal, Switch, Loading}, components: {Modal, Switch, Loading},
mixins: [SwitchMixin], mixins: [SwitchMixin],
} }

View file

@ -1,4 +1,4 @@
from typing import List from typing import List, Union
from platypush.plugins import Plugin, action from platypush.plugins import Plugin, action
@ -27,46 +27,34 @@ class SwitchPlugin(Plugin):
raise NotImplementedError() raise NotImplementedError()
@action @action
def switch_status(self, device=None): def switch_status(self, device=None) -> Union[dict, List[dict]]:
""" Get the status of a specified device or of all the configured devices (default)""" """
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 devices = self.switches
if device: if device:
devices = [d for d in self.switches if d.get('id') == device or d.get('name') == device] devices = [d for d in self.switches if d.get('id') == device or d.get('name') == device]
if devices: return devices[0] if devices else []
return devices[0]
else:
return None
return devices return devices
@action @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 Get the status of all the devices, or filter by device name or ID (alias for :meth:`.switch_status`).
does not handle only switches.
:param device: Filter by device name or ID.
:return: .. schema:: switch.SwitchStatusSchema(many=True)
""" """
return self.switch_status(device) return self.switch_status(device)
@property @property
def switches(self) -> List[dict]: def switches(self) -> List[dict]:
""" """
This property must be implemented by the derived classes and must return a dictionary in the following format: :return: .. schema:: switch.SwitchStatusSchema(many=True)
.. 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.
""" """
raise NotImplementedError() raise NotImplementedError()

View file

@ -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 <https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started>`_
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 <https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started>`_).
"""
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:

View file

@ -8,10 +8,10 @@ from platypush.plugins.bluetooth.ble import BluetoothBlePlugin
from platypush.plugins.switch import SwitchPlugin 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 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 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). 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``) * **pybluez** (``pip install pybluez``)
* **gattlib** (``pip install gattlib``) * **gattlib** (``pip install gattlib``)
* **libboost** (on Debian ```apt-get install libboost-python-dev libboost-thread-dev``) * **libboost** (on Debian ```apt-get install libboost-python-dev libboost-thread-dev``)
""" """
uuid = 'cba20002-224d-11e6-9fb8-0002a5d5c51b' uuid = 'cba20002-224d-11e6-9fb8-0002a5d5c51b'

View file

@ -53,6 +53,7 @@ class ZwavePlugin(ZwaveBasePlugin, SwitchPlugin):
def status(self) -> Dict[str, Any]: def status(self) -> Dict[str, Any]:
""" """
Get the status of the controller. Get the status of the controller.
:return: dict :return: dict
""" """
backend = self._get_backend() backend = self._get_backend()

View file

@ -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'))

View file

@ -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'))