Skip to content

Commit

Permalink
Add a base to allow easier testing of devices
Browse files Browse the repository at this point in the history
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..
  • Loading branch information
rytilahti committed Oct 21, 2017
1 parent ec71776 commit 3f6ed45
Show file tree
Hide file tree
Showing 4 changed files with 286 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]
56 changes: 56 additions & 0 deletions miio/tests/test_plug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 test_on(self):
self.device.off() # ensure off
is_on = lambda: self.device.status().is_on
start_state = is_on()
assert start_state == False

self.device.on()
assert is_on() == True


def test_off(self):
self.device.on() # ensure on
is_on = lambda: self.device.status().is_on
assert is_on() == True

self.device.off()
assert is_on() == False

def test_status(self):
self.device._reset_state()
state = lambda: self.device.status()
print(state())
assert state().is_on == True
assert state().temperature == self.device.start_state["temperature"]
assert state().load_power == self.device.start_state["current"] * 110

def test_status_without_current(self):
del self.device.state["current"]
state = lambda: self.device.status()
assert state().load_power is None
179 changes: 179 additions & 0 deletions miio/tests/test_yeelight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
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 == False
assert status.brightness == 100
assert status.color_temp == 3584
assert status.color_mode == YeelightMode.ColorTemperature
assert status.developer_mode == True
assert status.save_state_on_change == 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 == False
self.device.on()
assert self.device.status().is_on == True

def test_off(self):
self.device.on() # make sure we are on
assert self.device.status().is_on == True
self.device.off()
assert self.device.status().is_on == False

def test_set_brightness(self):
brightness = lambda: 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):
color_temp = lambda: 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):
dev_mode = lambda: 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):
save_state = lambda: 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):
name = lambda: self.device.status().name

assert name() == "test name"
self.device.set_name("new test name")
assert name() == "new test name"

def test_toggle(self):
is_on = lambda: 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 3f6ed45

Please sign in to comment.