[WIP] Refactoring `switchbot` plugin as a runnable plugin + entity manager

This commit is contained in:
Fabio Manganiello 2023-02-04 22:11:35 +01:00
parent 65827aa0cd
commit 2047b9b76c
Signed by: blacklight
GPG Key ID: D90FBA7F76362774
2 changed files with 325 additions and 107 deletions

View File

@ -1,38 +1,63 @@
import queue import queue
import requests
import threading import threading
from typing import List, Optional, Union from typing import Any, Collection, Dict, List, Optional, Union
from platypush.plugins import action import requests
from platypush.plugins.switch import SwitchPlugin
from platypush.entities import (
DimmerEntityManager,
EnumSwitchEntityManager,
Entity,
SwitchEntityManager,
)
from platypush.entities.devices import Device
from platypush.entities.dimmers import Dimmer
from platypush.entities.humidity import HumiditySensor
from platypush.entities.motion import MotionSensor
from platypush.entities.sensors import BinarySensor, EnumSensor, Sensor
from platypush.entities.switches import EnumSwitch
from platypush.entities.temperature import TemperatureSensor
from platypush.plugins import RunnablePlugin, action
from platypush.schemas.switchbot import DeviceSchema, DeviceStatusSchema, SceneSchema from platypush.schemas.switchbot import DeviceSchema, DeviceStatusSchema, SceneSchema
class SwitchbotPlugin(SwitchPlugin): class SwitchbotPlugin(
RunnablePlugin,
SwitchEntityManager,
DimmerEntityManager,
EnumSwitchEntityManager,
):
""" """
Plugin to interact with the devices registered to a Switchbot (https://www.switch-bot.com/) account/hub. 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 The difference between this plugin and
that the latter acts like a Bluetooth hub/bridge that interacts directly with your Switchbot devices, while this :class:`platypush.plugins.switchbot.bluetooth.SwitchbotBluetoothPlugin` is
plugin requires the devices to be connected to a Switchbot Hub and it controls them through your cloud account. 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: In order to use this plugin:
- Set up a Switchbot Hub and configure your devices through the Switchbot app. - Set up a Switchbot Hub and configure your devices through the
- Follow the steps on the `Switchbot API repo <https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started>`_ Switchbot app.
to get an API token from the 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): def __init__(self, api_token: str, **kwargs):
""" """
:param api_token: API token (see :param api_token: API token (see
`Getting started with the Switchbot API <https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started>`_). `Getting started with the Switchbot API
<https://github.com/OpenWonderLabs/SwitchBotAPI#getting-started>`_).
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self._api_token = api_token self._api_token = api_token
self._devices_by_id = {} self._devices_by_id: Dict[str, dict] = {}
self._devices_by_name = {} self._devices_by_name: Dict[str, dict] = {}
@staticmethod @staticmethod
def _url_for(*args, device=None): def _url_for(*args, device=None):
@ -42,6 +67,7 @@ class SwitchbotPlugin(SwitchPlugin):
url += '/'.join(args) url += '/'.join(args)
return url return url
# pylint: disable=keyword-arg-before-vararg
def _run(self, method: str = 'get', *args, device=None, **kwargs): def _run(self, method: str = 'get', *args, device=None, **kwargs):
response = getattr(requests, method)( response = getattr(requests, method)(
self._url_for(*args, device=device), self._url_for(*args, device=device),
@ -50,6 +76,7 @@ class SwitchbotPlugin(SwitchPlugin):
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
}, },
timeout=10,
**kwargs, **kwargs,
) )
@ -105,28 +132,150 @@ class SwitchbotPlugin(SwitchPlugin):
return devices return devices
def transform_entities(self, devices: List[dict]): @staticmethod
from platypush.entities.switches import Switch def _get_device_metadata(device: dict) -> dict:
return {
"device_type": device.get("device_type"),
"is_virtual": device.get("is_virtual", False),
"hub_id": device.get("hub_id"),
}
return super().transform_entities( # type: ignore @classmethod
[ def _get_device_base(cls, device_dict: dict) -> Device:
Switch( args: Dict[str, Any] = {
id=dev["id"], 'data': cls._get_device_metadata(device_dict),
name=dev["name"], }
state=dev.get("on"),
is_write_only=True, return Device(
data={ id=f'{device_dict["id"]}',
"device_type": dev.get("device_type"), name=f'{device_dict["name"]}',
"is_virtual": dev.get("is_virtual", False), **args,
"hub_id": dev.get("hub_id"),
},
)
for dev in (devices or [])
if dev.get('device_type') == 'Bot'
]
) )
def _worker( @classmethod
def _get_bots(cls, *entities: dict) -> List[EnumSwitch]:
return [
EnumSwitch(
id=dev["id"],
name=dev["name"],
value="on" if dev.get("on") else "off",
values=["on", "off", "press"],
is_write_only=True,
data=cls._get_device_metadata(dev),
)
for dev in (entities or [])
if dev.get('device_type') == 'Bot'
]
@classmethod
def _get_curtains(cls, *entities: dict) -> List[Dimmer]:
return [
Dimmer(
id=dev["id"],
name=dev["name"],
value=dev.get("position"),
min=0,
max=100,
unit='%',
data=cls._get_device_metadata(dev),
)
for dev in (entities or [])
if dev.get('device_type') == 'Curtain'
]
@classmethod
def _get_meters(cls, device_dict: dict) -> List[Device]:
devices = [cls._get_device_base(device_dict)]
if device_dict.get('temperature') is not None:
devices[0].children.append(
TemperatureSensor(
id=f'{device_dict["id"]}:temperature',
name='Temperature',
value=device_dict['temperature'],
unit='C',
)
)
if device_dict.get('humidity') is not None:
devices[0].children.append(
HumiditySensor(
id=f'{device_dict["id"]}:humidity',
name='Humidity',
value=device_dict['humidity'],
min=0,
max=100,
unit='%',
)
)
if not devices[0].children:
return []
return devices
@classmethod
def _get_motion_sensors(cls, device_dict: dict) -> List[Device]:
devices = [cls._get_device_base(device_dict)]
if device_dict.get('moveDetected') is not None:
devices[0].children.append(
MotionSensor(
id=f'{device_dict["id"]}:motion',
name='Motion Detected',
value=bool(device_dict['moveDetected']),
)
)
if device_dict.get('brightness') is not None:
devices[0].children.append(
BinarySensor(
id=f'{device_dict["id"]}:brightness',
name='Bright',
value=device_dict['brightness'] == 'bright',
)
)
if not devices[0].children:
return []
return devices
@classmethod
def _get_contact_sensors(cls, device_dict: dict) -> List[Device]:
devices = cls._get_motion_sensors(device_dict)
if not devices:
return []
if device_dict.get('openState') is not None:
devices[0].children.append(
EnumSensor(
id=f'{device_dict["id"]}:open',
name='Open State',
value=device_dict['openState'],
values=['open', 'close', 'timeOutNotClose'],
)
)
return devices
@classmethod
def _get_sensors(cls, *entities: dict) -> List[Sensor]:
sensors: List[Sensor] = []
for dev in entities:
if dev.get('device_type') in {'Meter', 'Meter Plus'}:
sensors.extend(cls._get_meters(dev))
elif dev.get('device_type') == 'Motion Sensor':
sensors.extend(cls._get_motion_sensors(dev))
elif dev.get('device_type') == 'Contact Sensor':
sensors.extend(cls._get_contact_sensors(dev))
return sensors
def transform_entities(self, entities: Collection[dict]) -> Collection[Entity]:
return [
*self._get_bots(*entities),
*self._get_curtains(*entities),
*self._get_sensors(*entities),
]
def _worker( # pylint: disable=keyword-arg-before-vararg
self, self,
q: queue.Queue, q: queue.Queue,
method: str = 'get', method: str = 'get',
@ -153,14 +302,16 @@ class SwitchbotPlugin(SwitchPlugin):
q.put(e) q.put(e)
@action @action
def status(self, device: Optional[str] = None) -> Union[dict, List[dict]]: # pylint: disable=arguments-differ
def status(
self, device: Optional[str] = None, publish_entities: bool = True, **_
) -> Union[dict, List[dict]]:
""" """
Get the status of all the registered devices or of a specific device. Get the status of all the registered devices or of a specific device.
:param device: Filter by device ID or name. :param device: Filter by device ID or name.
:return: .. schema:: switchbot.DeviceStatusSchema(many=True) :return: .. schema:: switchbot.DeviceStatusSchema(many=True)
""" """
# noinspection PyUnresolvedReferences
devices = self.devices().output devices = self.devices().output
if device: if device:
device_info = self._get_device(device) device_info = self._get_device(device)
@ -175,7 +326,7 @@ class SwitchbotPlugin(SwitchPlugin):
} }
devices_by_id = {dev['id']: dev for dev in devices} devices_by_id = {dev['id']: dev for dev in devices}
queues = [queue.Queue()] * len(devices) queues: List[queue.Queue] = [queue.Queue()] * len(devices)
workers = [ workers = [
threading.Thread( threading.Thread(
target=self._worker, target=self._worker,
@ -205,7 +356,8 @@ class SwitchbotPlugin(SwitchPlugin):
for worker in workers: for worker in workers:
worker.join() worker.join()
self.publish_entities(results) # type: ignore if publish_entities:
self.publish_entities(results)
return results return results
@action @action
@ -219,7 +371,7 @@ class SwitchbotPlugin(SwitchPlugin):
return self._run('post', 'commands', device=device, json={'command': 'press'}) return self._run('post', 'commands', device=device, json={'command': 'press'})
@action @action
def toggle(self, device: str, **kwargs): def toggle(self, device: str, **_): # pylint: disable=arguments-differ
""" """
Shortcut for :meth:`.press`. Shortcut for :meth:`.press`.
@ -228,7 +380,7 @@ class SwitchbotPlugin(SwitchPlugin):
return self.press(device) return self.press(device)
@action @action
def on(self, device: str, **kwargs): def on(self, device: str, **_): # pylint: disable=arguments-differ
""" """
Send a turn-on command to a device Send a turn-on command to a device
@ -238,7 +390,7 @@ class SwitchbotPlugin(SwitchPlugin):
return self._run('post', 'commands', device=device, json={'command': 'turnOn'}) return self._run('post', 'commands', device=device, json={'command': 'turnOn'})
@action @action
def off(self, device: str, **kwargs): def off(self, device: str, **_): # pylint: disable=arguments-differ
""" """
Send a turn-off command to a device Send a turn-off command to a device
@ -247,11 +399,6 @@ class SwitchbotPlugin(SwitchPlugin):
device = self._get_device(device) device = self._get_device(device)
return self._run('post', 'commands', device=device, json={'command': 'turnOff'}) 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 @action
def set_curtain_position(self, device: str, position: int): def set_curtain_position(self, device: str, position: int):
""" """
@ -278,7 +425,7 @@ class SwitchbotPlugin(SwitchPlugin):
Set the nebulization efficiency of a humidifier device. Set the nebulization efficiency of a humidifier device.
:param device: Device name or ID. :param device: Device name or ID.
:param efficiency: An integer between 0 (open) and 100 (closed) or `auto`. :param efficiency: An integer between 0 and 100, or `auto`.
""" """
device = self._get_device(device) device = self._get_device(device)
return self._run( return self._run(
@ -300,7 +447,6 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
:param speed: Speed between 1 and 4. :param speed: Speed between 1 and 4.
""" """
# noinspection PyUnresolvedReferences
status = self.status(device=device).output status = self.status(device=device).output
mode = status.get('mode') mode = status.get('mode')
swing_range = status.get('swing_range') swing_range = status.get('swing_range')
@ -323,7 +469,6 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
:param mode: Fan mode (1 or 2). :param mode: Fan mode (1 or 2).
""" """
# noinspection PyUnresolvedReferences
status = self.status(device=device).output status = self.status(device=device).output
speed = status.get('speed') speed = status.get('speed')
swing_range = status.get('swing_range') swing_range = status.get('swing_range')
@ -346,7 +491,6 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
:param swing_range: Swing range angle, between 0 and 120. :param swing_range: Swing range angle, between 0 and 120.
""" """
# noinspection PyUnresolvedReferences
status = self.status(device=device).output status = self.status(device=device).output
speed = status.get('speed') speed = status.get('speed')
mode = status.get('mode') mode = status.get('mode')
@ -369,7 +513,6 @@ class SwitchbotPlugin(SwitchPlugin):
:param device: Device name or ID. :param device: Device name or ID.
:param temperature: Temperature, in Celsius. :param temperature: Temperature, in Celsius.
""" """
# noinspection PyUnresolvedReferences
status = self.status(device=device).output status = self.status(device=device).output
mode = status.get('mode') mode = status.get('mode')
fan_speed = status.get('fan_speed') fan_speed = status.get('fan_speed')
@ -401,7 +544,6 @@ class SwitchbotPlugin(SwitchPlugin):
* 5: ``heat`` * 5: ``heat``
""" """
# noinspection PyUnresolvedReferences
status = self.status(device=device).output status = self.status(device=device).output
temperature = status.get('temperature') temperature = status.get('temperature')
fan_speed = status.get('fan_speed') fan_speed = status.get('fan_speed')
@ -432,7 +574,6 @@ class SwitchbotPlugin(SwitchPlugin):
* 4: ``high`` * 4: ``high``
""" """
# noinspection PyUnresolvedReferences
status = self.status(device=device).output status = self.status(device=device).output
temperature = status.get('temperature') temperature = status.get('temperature')
mode = status.get('mode') mode = status.get('mode')
@ -596,7 +737,7 @@ class SwitchbotPlugin(SwitchPlugin):
) )
@action @action
def stop(self, device: str): def ir_stop(self, device: str):
""" """
Send stop IR event to a device (for DVD and Speaker). Send stop IR event to a device (for DVD and Speaker).
@ -701,7 +842,6 @@ class SwitchbotPlugin(SwitchPlugin):
:param scene: Scene ID or name. :param scene: Scene ID or name.
""" """
# noinspection PyUnresolvedReferences
scenes = [ scenes = [
s s
for s in self.scenes().output for s in self.scenes().output
@ -711,5 +851,51 @@ class SwitchbotPlugin(SwitchPlugin):
assert scenes, f'No such scene: {scene}' assert scenes, f'No such scene: {scene}'
return self._run('post', 'scenes', scenes[0]['id'], 'execute') return self._run('post', 'scenes', scenes[0]['id'], 'execute')
@action
# pylint: disable=redefined-builtin,arguments-differ
def set_value(self, device: str, property=None, data=None, **__):
dev = self._get_device(device)
entities = list(self.transform_entities([dev]))
assert entities, f'The device {device} is not mapped to a compatible entity'
entity = entities[0]
# SwitchBot case
if isinstance(entity, EnumSwitch):
method = getattr(self, data, None)
assert method, f'No such action available for device "{device}": "{data}"'
return method(entity.id)
# Curtain case
if isinstance(entity, Dimmer):
return self.set_curtain_position(entity.id, data)
self.logger.warning(
'Could not find a suitable action for device "%s" of type "%s"',
device,
type(entity.__class__.__name__),
)
def main(self):
entities = {}
while not self.should_stop():
new_entities = {
e['id']: e for e in self.status(publish_entities=False).output
}
updated_entities = {
id: e
for id, e in new_entities.items()
if any(v != entities.get(id, {}).get(k) for k, v in e.items())
}
if updated_entities:
self.publish_entities(updated_entities.values())
entities = new_entities
self.wait_stop(self.poll_interval)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View File

@ -48,109 +48,141 @@ remote_types = [
class DeviceSchema(Schema): 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')) Base class for SwitchBot device schemas.
"""
id = fields.String(
attribute='deviceId',
required=True,
metadata={'description': 'Device unique ID'},
)
name = fields.String(
attribute='deviceName', metadata={'description': 'Device name'}
)
device_type = fields.String( device_type = fields.String(
attribute='deviceType', attribute='deviceType',
metadata=dict(description=f'Default types: [{", ".join(device_types)}]') metadata={'description': f'Default types: [{", ".join(device_types)}]'},
) )
remote_type = fields.String( remote_type = fields.String(
attribute='remoteType', attribute='remoteType',
metadata=dict(description=f'Default types: [{", ".join(remote_types)}]') metadata={'description': f'Default types: [{", ".join(remote_types)}]'},
)
hub_id = fields.String(
attribute='hubDeviceId',
metadata={'description': 'Parent hub device unique ID'},
) )
hub_id = fields.String(attribute='hubDeviceId', metadata=dict(description='Parent hub device unique ID'))
cloud_service_enabled = fields.Boolean( cloud_service_enabled = fields.Boolean(
attribute='enableCloudService', attribute='enableCloudService',
metadata=dict( metadata={
description='True if cloud access is enabled on this device,' 'description': 'True if cloud access is enabled on this device,'
'False otherwise. Only cloud-enabled devices can be ' 'False otherwise. Only cloud-enabled devices can be '
'controlled from the switchbot plugin.' 'controlled from the switchbot plugin.'
) },
) )
is_calibrated = fields.Boolean( is_calibrated = fields.Boolean(
attribute='calibrate', attribute='calibrate',
metadata=dict( metadata={
description='[Curtain devices only] Set to True if the device has been calibrated, False otherwise' 'description': '[Curtain devices only] Set to True if the device '
) 'has been calibrated, False otherwise'
},
) )
open_direction = fields.String( open_direction = fields.String(
attribute='openDirection', attribute='openDirection',
metadata=dict( metadata={
description='[Curtain devices only] Direction where the curtains will be opened ("left" or "right")' 'description': '[Curtain devices only] Direction where the curtains '
) 'will be opened ("left" or "right")'
},
) )
is_virtual = fields.Boolean( is_virtual = fields.Boolean(
metadata=dict( metadata={
description='True if this is a virtual device, i.e. a device with an IR remote configuration but not ' 'description': 'True if this is a virtual device, i.e. a device '
'managed directly by the Switchbot bridge' 'with an IR remote configuration but not managed directly by '
) 'the Switchbot bridge'
}
) )
class DeviceStatusSchema(DeviceSchema): class DeviceStatusSchema(DeviceSchema):
on = fields.Boolean(attribute='power', metadata=dict(description='True if the device is on, False otherwise')) """
Schema for SwitchBot devices status.
"""
on = fields.Boolean(
attribute='power',
metadata={'description': 'True if the device is on, False otherwise'},
)
moving = fields.Boolean( moving = fields.Boolean(
metadata=dict( metadata={
description='[Curtain devices only] True if the device is moving, False otherwise' 'description': '[Curtain devices only] True if the device is '
) 'moving, False otherwise'
}
) )
position = fields.Int( position = fields.Int(
attribute='slidePosition', metadata=dict( attribute='slidePosition',
description='[Curtain devices only] Position of the device on the curtain rail, between ' metadata={
'0 (open) and 1 (closed)' 'description': '[Curtain devices only] Position of the device on '
) 'the curtain rail, between 0 (open) and 1 (closed)'
},
) )
temperature = fields.Float( temperature = fields.Float(
metadata=dict(description='[Meter/humidifier/Air conditioner devices only] Temperature in Celsius') metadata={
'description': '[Meter/humidifier/Air conditioner devices only] '
'Temperature in Celsius'
}
)
humidity = fields.Float(
metadata={'description': '[Meter/humidifier devices only] Humidity in %'}
) )
humidity = fields.Float(metadata=dict(description='[Meter/humidifier devices only] Humidity in %'))
fan_speed = fields.Int( fan_speed = fields.Int(
metadata=dict(description='[Air conditioner devices only] Speed of the fan') metadata={'description': '[Air conditioner devices only] Speed of the fan'}
) )
nebulization_efficiency = fields.Float( nebulization_efficiency = fields.Float(
attribute='nebulizationEfficiency', attribute='nebulizationEfficiency',
metadata=dict( metadata={
description='[Humidifier devices only] Nebulization efficiency in %' 'description': '[Humidifier devices only] Nebulization efficiency in %'
) },
) )
auto = fields.Boolean( auto = fields.Boolean(
metadata=dict( metadata={'description': '[Humidifier devices only] True if auto mode is on'}
description='[Humidifier devices only] True if auto mode is on'
)
) )
child_lock = fields.Boolean( child_lock = fields.Boolean(
attribute='childLock', attribute='childLock',
metadata=dict( metadata={'description': '[Humidifier devices only] True if safety lock is on'},
description='[Humidifier devices only] True if safety lock is on'
)
) )
sound = fields.Boolean( sound = fields.Boolean(
metadata=dict( metadata={'description': '[Humidifier devices only] True if sound is muted'}
description='[Humidifier devices only] True if sound is muted'
)
) )
mode = fields.Int( mode = fields.Int(
metadata=dict(description='[Fan/Air conditioner devices only] Fan mode') metadata={'description': '[Fan/Air conditioner devices only] Fan mode'}
) )
speed = fields.Float( speed = fields.Float(
metadata=dict( metadata={'description': '[Smart fan devices only] Fan speed, between 1 and 4'}
description='[Smart fan devices only] Fan speed, between 1 and 4'
)
) )
swinging = fields.Boolean( swinging = fields.Boolean(
attribute='shaking', attribute='shaking',
metadata=dict(description='[Smart fan devices only] True if the device is swinging') metadata={
'description': '[Smart fan devices only] True if the device is swinging'
},
) )
swing_direction = fields.Int( swing_direction = fields.Int(
attribute='shakeCenter', attribute='shakeCenter',
metadata=dict(description='[Smart fan devices only] Swing direction') metadata={'description': '[Smart fan devices only] Swing direction'},
) )
swing_range = fields.Float( swing_range = fields.Float(
attribute='shakeRange', attribute='shakeRange',
metadata=dict(description='[Smart fan devices only] Swing range angle, between 0 and 120') metadata={
'description': '[Smart fan devices only] Swing range angle, between 0 and 120'
},
) )
class SceneSchema(Schema): 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')) Schema for SwitchBot scenes.
"""
id = fields.String(
attribute='sceneId', required=True, metadata={'description': 'Scene ID'}
)
name = fields.String(attribute='sceneName', metadata={'description': 'Scene name'})