Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Yeelight Dual Control Module (yeelink.switch.sw1) #887

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions miio/tests/dummies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
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:
Expand Down
91 changes: 91 additions & 0 deletions miio/tests/test_yeelight_dual_switch.py
Original file line number Diff line number Diff line change
@@ -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)
263 changes: 263 additions & 0 deletions miio/yeelight_dual_switch.py
Original file line number Diff line number Diff line change
@@ -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
rytilahti marked this conversation as resolved.
Show resolved Hide resolved

@property
def switch_1_state(self) -> bool:
"""First switch state"""
return bool(self.data["switch_1_state"])
rytilahti marked this conversation as resolved.
Show resolved Hide resolved

@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 = (
"<YeelightDualControlModuleStatus "
"switch_1_state=%s, "
"switch_1_default_state=%s, "
"switch_1_off_delay=%s, "
"switch_2_state=%s, "
"switch_2_default_state=%s, "
"switch_2_off_delay=%s, "
"interlock=%s, "
"flex_mode=%s, "
"rc_list=%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())
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
]
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)