diff --git a/README.rst b/README.rst index 5f7c4c595..8071a7b48 100644 --- a/README.rst +++ b/README.rst @@ -129,6 +129,7 @@ Supported devices - Xiaomi Mi Smart Space Heater - Xiaomiyoupin Curtain Controller (Wi-Fi) (lumi.curtain.hagl05) - Xiaomi Xiaomi Mi Smart Space Heater S (zhimi.heater.mc2) +- Yeelight Dual Control Module (yeelink.switch.sw1) *Feel free to create a pull request to add support for new devices as diff --git a/miio/__init__.py b/miio/__init__.py index addcbdd71..280bb3e4f 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -64,6 +64,7 @@ from miio.wifirepeater import WifiRepeater from miio.wifispeaker import WifiSpeaker from miio.yeelight import Yeelight +from miio.yeelight_dual_switch import YeelightDualControlModule from miio.discovery import Discovery diff --git a/miio/tests/dummies.py b/miio/tests/dummies.py index 5c3fbf734..333820c84 100644 --- a/miio/tests/dummies.py +++ b/miio/tests/dummies.py @@ -66,6 +66,16 @@ def __init__(self, *args, **kwargs): def get_properties_for_mapping(self): return self.state + def get_properties(self, properties): + """Return values only for listed properties""" + keys = [p["did"] for p in properties] + props = [] + for prop in self.state: + if prop["did"] in keys: + props.append(prop) + + return props + def set_property(self, property_key: str, value): for prop in self.state: if prop["did"] == property_key: diff --git a/miio/tests/test_yeelight_dual_switch.py b/miio/tests/test_yeelight_dual_switch.py new file mode 100644 index 000000000..7808b6f90 --- /dev/null +++ b/miio/tests/test_yeelight_dual_switch.py @@ -0,0 +1,91 @@ +from unittest import TestCase + +import pytest + +from miio import YeelightDualControlModule +from miio.yeelight_dual_switch import Switch, YeelightDualControlModuleException + +from .dummies import DummyMiotDevice + +_INITIAL_STATE = { + "switch_1_state": True, + "switch_1_default_state": True, + "switch_1_off_delay": 300, + "switch_2_state": False, + "switch_2_default_state": False, + "switch_2_off_delay": 0, + "interlock": False, + "flex_mode": True, + "rc_list": "[{'mac':'9db0eb4124f8','evtid':4097,'pid':339,'beaconkey':'3691bc0679eef9596bb63abf'}]", +} + + +class DummyYeelightDualControlModule(DummyMiotDevice, YeelightDualControlModule): + def __init__(self, *args, **kwargs): + self.state = _INITIAL_STATE + self.return_values = { + "get_prop": self._get_state, + } + super().__init__(*args, **kwargs) + + +@pytest.fixture(scope="function") +def switch(request): + request.cls.device = DummyYeelightDualControlModule() + + +@pytest.mark.usefixtures("switch") +class TestYeelightDualControlModule(TestCase): + def test_1_on(self): + self.device.off(Switch.First) # ensure off + print(self.device.status()) + assert self.device.status().switch_1_state is False + + self.device.on(Switch.First) + assert self.device.status().switch_1_state is True + + def test_2_on(self): + self.device.off(Switch.Second) # ensure off + assert self.device.status().switch_2_state is False + + self.device.on(Switch.Second) + assert self.device.status().switch_2_state is True + + def test_1_off(self): + self.device.on(Switch.First) # ensure on + assert self.device.status().switch_1_state is True + + self.device.off(Switch.First) + assert self.device.status().switch_1_state is False + + def test_2_off(self): + self.device.on(Switch.Second) # ensure on + assert self.device.status().switch_2_state is True + + self.device.off(Switch.Second) + assert self.device.status().switch_2_state is False + + def test_status(self): + status = self.device.status() + + assert status.switch_1_state is _INITIAL_STATE["switch_1_state"] + assert status.switch_1_off_delay == _INITIAL_STATE["switch_1_off_delay"] + assert status.switch_1_default_state == _INITIAL_STATE["switch_1_default_state"] + assert status.switch_1_state is _INITIAL_STATE["switch_1_state"] + assert status.switch_1_off_delay == _INITIAL_STATE["switch_1_off_delay"] + assert status.switch_1_default_state == _INITIAL_STATE["switch_1_default_state"] + assert status.interlock == _INITIAL_STATE["interlock"] + assert status.flex_mode == _INITIAL_STATE["flex_mode"] + assert status.rc_list == _INITIAL_STATE["rc_list"] + + def test_set_switch_off_delay(self): + self.device.set_switch_off_delay(300, Switch.First) + assert self.device.status().switch_1_off_delay == 300 + self.device.set_switch_off_delay(200, Switch.Second) + assert self.device.status().switch_2_off_delay == 200 + + with pytest.raises(YeelightDualControlModuleException): + self.device.set_switch_off_delay(-2, Switch.First) + + with pytest.raises(YeelightDualControlModuleException): + self.device.set_switch_off_delay(43300, Switch.Second) diff --git a/miio/yeelight_dual_switch.py b/miio/yeelight_dual_switch.py new file mode 100644 index 000000000..5b059075c --- /dev/null +++ b/miio/yeelight_dual_switch.py @@ -0,0 +1,263 @@ +import enum +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .exceptions import DeviceException +from .miot_device import MiotDevice + + +class YeelightDualControlModuleException(DeviceException): + pass + + +class Switch(enum.Enum): + First = 0 + Second = 1 + + +_MAPPING = { + # http://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:switch:0000A003:yeelink-sw1:1:0000C809 + # First Switch (siid=2) + "switch_1_state": {"siid": 2, "piid": 1}, # bool + "switch_1_default_state": {"siid": 2, "piid": 2}, # 0 - Off, 1 - On + "switch_1_off_delay": {"siid": 2, "piid": 3}, # -1 - Off, [1, 43200] - delay in sec + # Second Switch (siid=3) + "switch_2_state": {"siid": 3, "piid": 1}, # bool + "switch_2_default_state": {"siid": 3, "piid": 2}, # 0 - Off, 1 - On + "switch_2_off_delay": {"siid": 3, "piid": 3}, # -1 - Off, [1, 43200] - delay in sec + # Extensions (siid=4) + "interlock": {"siid": 4, "piid": 1}, # bool + "flex_mode": {"siid": 4, "piid": 2}, # 0 - Off, 1 - On + "rc_list": {"siid": 4, "piid": 3}, # string + "rc_list_for_del": {"siid": 4, "piid": 4}, # string + "toggle": {"siid": 4, "piid": 5}, # 0 - First switch, 1 - Second switch +} + + +class DualControlModuleStatus: + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response of Yeelight Dual Control Module + { + 'id': 1, + 'result': [ + {'did': 'switch_1_state', 'siid': 2, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'switch_1_default_state', 'siid': 2, 'piid': 2, 'code': 0, 'value': True}, + {'did': 'switch_1_off_delay', 'siid': 2, 'piid': 3, 'code': 0, 'value': 300}, + {'did': 'switch_2_state', 'siid': 3, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'switch_2_default_state', 'siid': 3, 'piid': 2, 'code': 0, 'value': False}, + {'did': 'switch_2_off_delay', 'siid': 3, 'piid': 3, 'code': 0, 'value': 0}, + {'did': 'interlock', 'siid': 4, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'flex_mode', 'siid': 4, 'piid': 2, 'code': 0, 'value': True}, + {'did': 'rc_list', 'siid': 4, 'piid': 2, 'code': 0, 'value': '[{"mac":"9db0eb4124f8","evtid":4097,"pid":339,"beaconkey":"3691bc0679eef9596bb63abf"}]'}, + ] + } + """ + self.data = data + + @property + def switch_1_state(self) -> bool: + """First switch state""" + return bool(self.data["switch_1_state"]) + + @property + def switch_1_default_state(self) -> bool: + """First switch default state""" + return bool(self.data["switch_1_default_state"]) + + @property + def switch_1_off_delay(self) -> int: + """First switch off delay""" + return self.data["switch_1_off_delay"] + + @property + def switch_2_state(self) -> bool: + """Second switch state""" + return bool(self.data["switch_2_state"]) + + @property + def switch_2_default_state(self) -> bool: + """Second switch default state""" + return bool(self.data["switch_2_default_state"]) + + @property + def switch_2_off_delay(self) -> int: + """Second switch off delay""" + return self.data["switch_2_off_delay"] + + @property + def interlock(self) -> bool: + """Interlock""" + return bool(self.data["interlock"]) + + @property + def flex_mode(self) -> int: + """Flex mode""" + return self.data["flex_mode"] + + @property + def rc_list(self) -> str: + """List of paired remote controls""" + return self.data["rc_list"] + + def __repr__(self) -> str: + s = ( + "" + % ( + self.switch_1_state, + self.switch_1_default_state, + self.switch_1_off_delay, + self.switch_2_state, + self.switch_2_default_state, + self.switch_2_off_delay, + self.interlock, + self.flex_mode, + self.rc_list, + ) + ) + return s + + +class YeelightDualControlModule(MiotDevice): + """Main class representing the Yeelight Dual Control Module (yeelink.switch.sw1) which uses MIoT protocol.""" + + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + ) -> None: + super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + + @command( + default_output=format_output( + "", + "First Switch Status: {result.switch_1_state}\n" + "First Switch Default State: {result.switch_1_default_state}\n" + "First Switch Delay: {result.switch_1_off_delay}\n" + "Second Switch Status: {result.switch_2_state}\n" + "Second Switch Default State: {result.switch_2_default_state}\n" + "Second Switch Delay: {result.switch_2_off_delay}\n" + "Interlock: {result.interlock}\n" + "Flex Mode: {result.flex_mode}\n" + "RC list: {result.rc_list}\n", + ) + ) + def status(self) -> DualControlModuleStatus: + """Retrieve properties""" + p = [ + "switch_1_state", + "switch_1_default_state", + "switch_1_off_delay", + "switch_2_state", + "switch_2_default_state", + "switch_2_off_delay", + "interlock", + "flex_mode", + "rc_list", + ] + """Filter only readable properties for status""" + properties = [ + {"did": k, **v} + for k, v in filter(lambda item: item[0] in p, _MAPPING.items()) + ] + values = self.get_properties(properties) + return DualControlModuleStatus( + dict(map(lambda v: (v["did"], v["value"]), values)) + ) + + @command( + click.argument("switch", type=EnumType(Switch)), + default_output=format_output("Turn {switch} switch on"), + ) + def on(self, switch: Switch): + """Turn switch on.""" + if switch == Switch.First: + return self.set_property("switch_1_state", True) + elif switch == Switch.Second: + return self.set_property("switch_2_state", True) + + @command( + click.argument("switch", type=EnumType(Switch)), + default_output=format_output("Turn {switch} switch off"), + ) + def off(self, switch: Switch): + """Turn switch off.""" + if switch == Switch.First: + return self.set_property("switch_1_state", False) + elif switch == Switch.Second: + return self.set_property("switch_2_state", False) + + @command( + click.argument("switch", type=EnumType(Switch)), + default_output=format_output("Toggle {switch} switch"), + ) + def toggle(self, switch: Switch): + """Toggle switch.""" + return self.set_property("toggle", switch.value) + + @command( + click.argument("state", type=bool), + click.argument("switch", type=EnumType(Switch)), + default_output=format_output("Set {switch} switch default state to: {state}"), + ) + def set_default_state(self, state: bool, switch: Switch): + """Set switch default state.""" + if switch == Switch.First: + return self.set_property("switch_1_default_state", int(state)) + elif switch == Switch.Second: + return self.set_property("switch_2_default_state", int(state)) + + @command( + click.argument("delay", type=int), + click.argument("switch", type=EnumType(Switch)), + default_output=format_output("Set {switch} switch off delay to {delay} sec."), + ) + def set_switch_off_delay(self, delay: int, switch: Switch): + """Set switch off delay, should be between -1 to 43200 (in seconds)""" + if delay < -1 or delay > 43200: + raise YeelightDualControlModuleException( + "Invalid switch delay: %s (should be between -1 to 43200)" % delay + ) + + if switch == Switch.First: + return self.set_property("switch_1_off_delay", delay) + elif switch == Switch.Second: + return self.set_property("switch_2_off_delay", delay) + + @command( + click.argument("flex_mode", type=bool), + default_output=format_output("Set flex mode to: {flex_mode}"), + ) + def set_flex_mode(self, flex_mode: bool): + """Set flex mode.""" + return self.set_property("flex_mode", int(flex_mode)) + + @command( + click.argument("rc_mac", type=str), + default_output=format_output("Delete remote control with MAC: {rc_mac}"), + ) + def delete_rc(self, rc_mac: str): + """Delete remote control by MAC""" + return self.set_property("rc_list_for_del", rc_mac) + + @command( + click.argument("interlock", type=bool), + default_output=format_output("Set interlock to: {interlock}"), + ) + def set_interlock(self, interlock: bool): + """Set interlock""" + return self.set_property("interlock", interlock)