From c859d51b50fa73b1a6c14e1d83cb21a8b17bd6b9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 23 Oct 2017 22:53:14 +0200 Subject: [PATCH] Add a base to allow easier testing of devices (#99) * Add a base to allow easier testing of devices To demonstrate its functionality unittests for yeelight and plug are included in this commit. It also allowed to spot a couple of bugs in yeelight already.. * make hound happy again --- miio/tests/dummies.py | 42 ++++++++ miio/tests/test_plug.py | 61 ++++++++++++ miio/tests/test_yeelight.py | 189 ++++++++++++++++++++++++++++++++++++ miio/yeelight.py | 10 +- 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 miio/tests/dummies.py create mode 100644 miio/tests/test_plug.py create mode 100644 miio/tests/test_yeelight.py diff --git a/miio/tests/dummies.py b/miio/tests/dummies.py new file mode 100644 index 000000000..1b8fbd88e --- /dev/null +++ b/miio/tests/dummies.py @@ -0,0 +1,42 @@ +class DummyDevice: + """DummyDevice base class, you should inherit from this and call + `super().__init__(args, kwargs)` to save the original state. + + This class provides helpers to test simple devices, for more complex + ones you will want to extend the `return_values` accordingly. + The basic idea is that the overloaded send() will read a wanted response + based on the call from `return_values`. + + For changing values :func:`_set_state` will use :func:`pop()` to extract + the first parameter and set the state accordingly. + + For a very simple device the following is enough, see :class:`TestPlug` + for complete code. + + .. code-block:: + self.return_values = { + "get_prop": self._get_state, + "power": lambda x: self._set_state("power", x) + } + + """ + def __init__(self, *args, **kwargs): + self.start_state = self.state.copy() + + def send(self, command: str, parameters=None, retry_count=3): + """Overridden send() to return values from `self.return_values`.""" + return self.return_values[command](parameters) + + def _reset_state(self): + """Revert back to the original state.""" + self.state = self.start_state.copy() + + def _set_state(self, var, value): + """Set a state of a variable, + the value is expected to be an array with length of 1.""" + # print("setting %s = %s" % (var, value)) + self.state[var] = value.pop(0) + + def _get_state(self, props): + """Return wanted properties""" + return [self.state[x] for x in props if x in self.state] diff --git a/miio/tests/test_plug.py b/miio/tests/test_plug.py new file mode 100644 index 000000000..53469b552 --- /dev/null +++ b/miio/tests/test_plug.py @@ -0,0 +1,61 @@ +from unittest import TestCase +from miio import Plug +from .dummies import DummyDevice +import pytest + + +class DummyPlug(DummyDevice, Plug): + def __init__(self, *args, **kwargs): + self.state = { + 'power': 'on', + 'temperature': 32, + 'current': 123, + } + self.return_values = { + 'get_prop': self._get_state, + 'set_power': lambda x: self._set_state("power", x), + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def plug(request): + request.cls.device = DummyPlug() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("plug") +class TestPlug(TestCase): + def is_on(self): + return self.device.status().is_on + + def state(self): + return self.device.status() + + def test_on(self): + self.device.off() # ensure off + + start_state = self.is_on() + assert start_state is False + + self.device.on() + assert self.is_on() is True + + def test_off(self): + self.device.on() # ensure on + + assert self.is_on() is True + self.device.off() + assert self.is_on() is False + + def test_status(self): + self.device._reset_state() + + assert self.is_on() is True + assert self.state().temperature == self.device.start_state["temperature"] + assert self.state().load_power == self.device.start_state["current"] * 110 + + def test_status_without_current(self): + del self.device.state["current"] + + assert self.state().load_power is None diff --git a/miio/tests/test_yeelight.py b/miio/tests/test_yeelight.py new file mode 100644 index 000000000..c2851e632 --- /dev/null +++ b/miio/tests/test_yeelight.py @@ -0,0 +1,189 @@ +from unittest import TestCase +from miio import Yeelight +from miio.yeelight import YeelightMode, YeelightStatus, YeelightException +import pytest +from .dummies import DummyDevice + + +class DummyLight(DummyDevice, Yeelight): + def __init__(self, *args, **kwargs): + self.state = { + 'power': 'off', + 'bright': '100', + 'ct': '3584', + 'rgb': '16711680', + 'hue': '359', + 'sat': '100', + 'color_mode': '2', + 'name': 'test name', + 'lan_ctrl': '1', + 'save_state': '1' + } + + self.return_values = { + 'get_prop': self._get_state, + 'set_power': lambda x: self._set_state("power", x), + 'set_bright': lambda x: self._set_state("bright", x), + 'set_ct_abx': lambda x: self._set_state("ct", x), + 'set_rgb': lambda x: self._set_state("rgb", x), + 'set_hsv': lambda x: self._set_state("hsv", x), + 'set_name': lambda x: self._set_state("name", x), + 'set_ps': lambda x: self.set_config(x), + 'toggle': self.toggle_power, + 'set_default': lambda x: 'ok' + } + + super().__init__(*args, **kwargs) + + def set_config(self, x): + key, value = x + config_mapping = { + 'cfg_lan_ctrl': 'lan_ctrl', + 'cfg_save_state': 'save_state' + } + + self._set_state(config_mapping[key], [value]) + + def toggle_power(self, _): + if self.state["power"] == "on": + self.state["power"] = "off" + else: + self.state["power"] = "on" + + +@pytest.fixture(scope="class") +def dummylight(request): + request.cls.device = DummyLight() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("dummylight") +class TestYeelight(TestCase): + def test_status(self): + self.device._reset_state() + status = self.device.status() # type: YeelightStatus + assert status.name == self.device.start_state["name"] + assert status.is_on is False + assert status.brightness == 100 + assert status.color_temp == 3584 + assert status.color_mode == YeelightMode.ColorTemperature + assert status.developer_mode is True + assert status.save_state_on_change is True + + # following are tested in set mode tests + # assert status.rgb == 16711680 + # assert status.hsv == (359, 100, 100) + + def test_on(self): + self.device.off() # make sure we are off + assert self.device.status().is_on is False + self.device.on() + assert self.device.status().is_on is True + + def test_off(self): + self.device.on() # make sure we are on + assert self.device.status().is_on is True + self.device.off() + assert self.device.status().is_on is False + + def test_set_brightness(self): + def brightness(): + return self.device.status().brightness + + self.device.set_brightness(50) + assert brightness() == 50 + self.device.set_brightness(0) + assert brightness() == 0 + self.device.set_brightness(100) + + with pytest.raises(YeelightException): + self.device.set_brightness(-100) + + with pytest.raises(YeelightException): + self.device.set_brightness(200) + + def test_set_color_temp(self): + def color_temp(): + return self.device.status().color_temp + + self.device.set_color_temp(2000) + assert color_temp() == 2000 + self.device.set_color_temp(6500) + assert color_temp() == 6500 + + with pytest.raises(YeelightException): + self.device.set_color_temp(1000) + + with pytest.raises(YeelightException): + self.device.set_color_temp(7000) + + @pytest.mark.skip("rgb is not properly implemented") + def test_set_rgb(self): + self.device._reset_state() + assert self.device.status().rgb == 16711680 + + NEW_RGB = 16712222 + self.set_rgb(NEW_RGB) + assert self.device.status().rgb == NEW_RGB + + @pytest.mark.skip("hsv is not properly implemented") + def test_set_hsv(self): + self.reset_state() + hue, sat, val = self.device.status().hsv + assert hue == 359 + assert sat == 100 + assert val == 100 + + self.device.set_hsv() + + def test_set_developer_mode(self): + def dev_mode(): + return self.device.status().developer_mode + + orig_mode = dev_mode() + self.device.set_developer_mode(not orig_mode) + new_mode = dev_mode() + assert new_mode is not orig_mode + self.device.set_developer_mode(not new_mode) + assert new_mode is not dev_mode() + + def test_set_save_state_on_change(self): + def save_state(): + return self.device.status().save_state_on_change + + orig_state = save_state() + self.device.set_save_state_on_change(not orig_state) + new_state = save_state() + assert new_state is not orig_state + self.device.set_save_state_on_change(not new_state) + new_state = save_state() + assert new_state is orig_state + + def test_set_name(self): + def name(): + return self.device.status().name + + assert name() == "test name" + self.device.set_name("new test name") + assert name() == "new test name" + + def test_toggle(self): + def is_on(): + return self.device.status().is_on + + orig_state = is_on() + self.device.toggle() + new_state = is_on() + assert orig_state != new_state + + self.device.toggle() + new_state = is_on() + assert new_state == orig_state + + @pytest.mark.skip("cannot be tested easily") + def test_set_default(self): + self.fail() + + @pytest.mark.skip("set_scene is not implemented") + def test_set_scene(self): + self.fail() diff --git a/miio/yeelight.py b/miio/yeelight.py index 85ee3a30d..b45d0a0bd 100644 --- a/miio/yeelight.py +++ b/miio/yeelight.py @@ -4,6 +4,10 @@ import warnings +class YeelightException(Exception): + pass + + class YeelightMode(IntEnum): RGB = 1 ColorTemperature = 2 @@ -145,10 +149,14 @@ def off(self): def set_brightness(self, bright): """Set brightness.""" + if bright < 0 or bright > 100: + raise YeelightException("Invalid brightness: %s" % bright) return self.send("set_bright", [bright]) def set_color_temp(self, ct): """Set color temp in kelvin.""" + if ct > 6500 or ct < 1700: + raise YeelightException("Invalid color temperature: %s" % ct) return self.send("set_ct_abx", [ct, "smooth", 500]) def set_rgb(self, rgb): @@ -165,7 +173,7 @@ def set_developer_mode(self, enable: bool) -> bool: def set_save_state_on_change(self, enable: bool) -> bool: """Enable or disable saving the state on changes.""" - return self.send("set_ps", ["cfg_save_state"], str(int(enable))) + return self.send("set_ps", ["cfg_save_state", str(int(enable))]) def set_name(self, name: str) -> bool: """Set an internal name for the bulb."""