Skip to content

Commit

Permalink
Add a base to allow easier testing of devices (#99)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
rytilahti authored Oct 23, 2017
1 parent 61874e3 commit c859d51
Show file tree
Hide file tree
Showing 4 changed files with 301 additions and 1 deletion.
42 changes: 42 additions & 0 deletions miio/tests/dummies.py
Original file line number Diff line number Diff line change
@@ -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]
61 changes: 61 additions & 0 deletions miio/tests/test_plug.py
Original file line number Diff line number Diff line change
@@ -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
189 changes: 189 additions & 0 deletions miio/tests/test_yeelight.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 9 additions & 1 deletion miio/yeelight.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import warnings


class YeelightException(Exception):
pass


class YeelightMode(IntEnum):
RGB = 1
ColorTemperature = 2
Expand Down Expand Up @@ -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):
Expand All @@ -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."""
Expand Down

0 comments on commit c859d51

Please sign in to comment.