diff --git a/README.rst b/README.rst
index 7dfef9457..7adb702b5 100644
--- a/README.rst
+++ b/README.rst
@@ -27,7 +27,7 @@ Supported devices
- Xiaomi Philips LED Ball Lamp (:class:`miio.philips_bulb`)
- Xiaomi Philips Zhirui Smart LED Bulb E14 Candle Lamp (:class:`miio.philips_bulb`)
- Xiaomi Universal IR Remote Controller (Chuangmi IR) (:class:`miio.chuangmi_ir`)
-- Xiaomi Mi Smart Fan (:class:`miio.fan`)
+- Xiaomi Mi Smart Pedestal Fan (:class:`miio.fan`)
- Xiaomi Mi Air Humidifier (:class:`miio.airhumidifier`)
- Xiaomi Mi Water Purifier (Basic support: Turn on & off) (:class:`miio.waterpurifier`)
- Xiaomi PM2.5 Air Quality Monitor (:class:`miio.airqualitymonitor`)
@@ -55,7 +55,8 @@ Home Assistant support
- `Xiaomi Universal IR Remote Controller `__
- `Xiaomi Mi Air Quality Monitor (PM2.5) `__
- `Xiaomi Mi Home Air Conditioner Companion `__
-- `Xiaomi Mi WiFi Repeater 2 `__
+- `Xiaomi Mi WiFi Repeater 2 `__
+- `Xiaomi Mi Smart Pedestal Fan `__
- `Xiaomi Raw Sensor `__
diff --git a/miio/discovery.py b/miio/discovery.py
index 40b4aa0ce..b5cf7d793 100644
--- a/miio/discovery.py
+++ b/miio/discovery.py
@@ -12,6 +12,7 @@
WaterPurifier, WifiSpeaker, WifiRepeater, Yeelight, Fan, )
from .chuangmi_plug import (MODEL_CHUANGMI_PLUG_V1, MODEL_CHUANGMI_PLUG_V3,
MODEL_CHUANGMI_PLUG_M1, )
+from .fan import (MODEL_FAN_V2, MODEL_FAN_V3, )
from .powerstrip import (MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2, )
_LOGGER = logging.getLogger(__name__)
@@ -52,8 +53,8 @@
"xiaomi-repeater-v1": WifiRepeater, # name needs to be checked
"xiaomi-repeater-v3": WifiRepeater, # name needs to be checked
"yeelink-light-": Yeelight,
- "zhimi-fan-v2": Fan,
- "zhimi-fan-v3": Fan,
+ "zhimi-fan-v2": partial(Fan, model=MODEL_FAN_V2),
+ "zhimi-fan-v3": partial(Fan, model=MODEL_FAN_V3),
"lumi-gateway-": lambda x: other_package_info(
x, "https://github.com/Danielhiversen/PyXiaomiGateway")
} # type: Dict[str, Union[Callable, Device]]
diff --git a/miio/fan.py b/miio/fan.py
index bc7287742..be8720a1d 100644
--- a/miio/fan.py
+++ b/miio/fan.py
@@ -5,10 +5,42 @@
import click
from .click_common import command, format_output, EnumType
-from .device import Device
+from .device import Device, DeviceException
_LOGGER = logging.getLogger(__name__)
+MODEL_FAN_V2 = 'zimi.fan.v2'
+MODEL_FAN_V3 = 'zimi.fan.v3'
+
+AVAILABLE_PROPERTIES_COMMON = [
+ 'temp_dec',
+ 'humidity',
+ 'angle',
+ 'speed',
+ 'poweroff_time',
+ 'power',
+ 'ac_power',
+ 'battery',
+ 'angle_enable',
+ 'speed_level',
+ 'natural_level',
+ 'child_lock',
+ 'buzzer',
+ 'led_b',
+ 'use_time',
+ 'bat_charge',
+ 'button_pressed',
+]
+
+AVAILABLE_PROPERTIES = {
+ MODEL_FAN_V2: ['led', 'bat_state'] + AVAILABLE_PROPERTIES_COMMON,
+ MODEL_FAN_V3: AVAILABLE_PROPERTIES_COMMON,
+}
+
+
+class FanException(DeviceException):
+ pass
+
class LedBrightness(enum.Enum):
Bright = 0
@@ -22,15 +54,19 @@ class MoveDirection(enum.Enum):
class FanStatus:
- """Container for status reports from the Xiaomi Smart Fan."""
+ """Container for status reports from the Xiaomi Mi Smart Pedestal Fan."""
def __init__(self, data: Dict[str, Any]) -> None:
- # ['temp_dec', 'humidity', 'angle', 'speed', 'poweroff_time', 'power',
- # 'ac_power', 'battery', 'angle_enable', 'speed_level',
- # 'natural_level', 'child_lock', 'buzzer', 'led_b', 'led']
- #
- # [232, 46, 30, 298, 0, 'on', 'off', 98, 'off', 1, 0, 'off', 'on',
- # 1, 'on']
+ """
+ Response of a Fan (zhimi.fan.v3):
+
+ {'temp_dec': 232, 'humidity': 46, 'angle': 118, 'speed': 298,
+ 'poweroff_time': 0, 'power': 'on', 'ac_power': 'off', 'battery': 98,
+ 'angle_enable': 'off', 'speed_level': 1, 'natural_level': 0,
+ 'child_lock': 'off', 'buzzer': 'on', 'led_b': 1, 'led': None,
+ 'natural_enable': None, 'use_time': 0, 'bat_charge': 'complete',
+ 'bat_state': None, 'button_pressed':'speed'}
+ """
self.data = data
@property
@@ -49,16 +85,16 @@ def humidity(self) -> int:
return self.data["humidity"]
@property
- def temperature(self) -> Optional[float]:
+ def temperature(self) -> float:
"""Current temperature, if available."""
- if self.data["temp_dec"] is not None:
- return self.data["temp_dec"] / 10.0
- return None
+ return self.data["temp_dec"] / 10.0
@property
- def led(self) -> bool:
- """True if LED is turned on."""
- return self.data["led"] == "on"
+ def led(self) -> Optional[bool]:
+ """True if LED is turned on, if available."""
+ if "led" in self.data and self.data["led"] is not None:
+ return self.data["led"] == "on"
+ return None
@property
def led_brightness(self) -> Optional[LedBrightness]:
@@ -78,13 +114,13 @@ def child_lock(self) -> bool:
return self.data["child_lock"] == "on"
@property
- def natural_level(self) -> int:
- """Fan speed in natural mode."""
+ def natural_speed(self) -> int:
+ """Speed level in natural mode."""
return self.data["natural_level"]
@property
- def speed_level(self) -> int:
- """Fan speed in direct mode."""
+ def direct_speed(self) -> int:
+ """Speed level in direct mode."""
return self.data["speed_level"]
@property
@@ -97,20 +133,33 @@ def battery(self) -> int:
"""Current battery level."""
return self.data["battery"]
+ @property
+ def battery_charge(self) -> Optional[str]:
+ """State of the battery charger, if available."""
+ if self.data["bat_charge"] is not None:
+ return self.data["bat_charge"]
+ return None
+
+ @property
+ def battery_state(self) -> Optional[str]:
+ """State of the battery, if available."""
+ if "bat_state" in self.data and self.data["bat_state"] is not None:
+ return self.data["bat_state"]
+ return None
+
@property
def ac_power(self) -> bool:
"""True if powered by AC."""
return self.data["ac_power"] == "on"
@property
- def poweroff_time(self) -> int:
- """Time until turning off. FIXME verify"""
+ def delay_off_countdown(self) -> int:
+ """Countdown until turning off in seconds."""
return self.data["poweroff_time"]
@property
def speed(self) -> int:
- """FIXME What is the meaning of this value?
- (cp. speed_level vs. natural_level)"""
+ """Speed of the motor."""
return self.data["speed"]
@property
@@ -118,7 +167,19 @@ def angle(self) -> int:
"""Current angle."""
return self.data["angle"]
- def __str__(self) -> str:
+ @property
+ def use_time(self) -> int:
+ """How long the device has been active in seconds."""
+ return self.data["use_time"]
+
+ @property
+ def button_pressed(self) -> Optional[str]:
+ """Last pressed button."""
+ if self.data["button_pressed"] is not None:
+ return self.data["button_pressed"]
+ return None
+
+ def __repr__(self) -> str:
s = " str:
"led_brightness=%s, " \
"buzzer=%s, " \
"child_lock=%s, " \
- "natural_level=%s, " \
- "speed_level=%s, " \
+ "natural_speed=%s, " \
+ "direct_speed=%s, " \
+ "speed=%s, " \
"oscillate=%s, " \
- "battery=%s, " \
+ "angle=%s, " \
"ac_power=%s, " \
- "poweroff_time=%s, " \
- "speed=%s, " \
- "angle=%s" % \
+ "battery=%s, " \
+ "battery_charge=%s, " \
+ "battery_state=%s, " \
+ "use_time=%s, " \
+ "delay_off_countdown=%s, " \
+ "button_pressed=%s>" % \
(self.power,
self.temperature,
self.humidity,
@@ -141,14 +206,18 @@ def __str__(self) -> str:
self.led_brightness,
self.buzzer,
self.child_lock,
- self.natural_level,
- self.speed_level,
+ self.natural_speed,
+ self.direct_speed,
+ self.speed,
self.oscillate,
- self.battery,
+ self.angle,
self.ac_power,
- self.poweroff_time,
- self.speed_level,
- self.angle)
+ self.battery,
+ self.battery_charge,
+ self.battery_state,
+ self.use_time,
+ self.delay_off_countdown,
+ self.button_pressed)
return s
def __json__(self):
@@ -156,7 +225,17 @@ def __json__(self):
class Fan(Device):
- """Main class representing the Xiaomi Smart Fan."""
+ """Main class representing the Xiaomi Mi Smart Pedestal Fan."""
+
+ def __init__(self, ip: str = None, token: str = None, start_id: int = 0,
+ debug: int = 0, lazy_discover: bool = True,
+ model: str = MODEL_FAN_V3) -> None:
+ super().__init__(ip, token, start_id, debug, lazy_discover)
+
+ if model in AVAILABLE_PROPERTIES:
+ self.model = model
+ else:
+ self.model = MODEL_FAN_V3
@command(
default_output=format_output(
@@ -180,11 +259,7 @@ class Fan(Device):
)
def status(self) -> FanStatus:
"""Retrieve properties."""
- properties = ['temp_dec', 'humidity', 'angle', 'speed',
- 'poweroff_time', 'power', 'ac_power', 'battery',
- 'angle_enable', 'speed_level', 'natural_level',
- 'child_lock', 'buzzer', 'led_b', 'led']
-
+ properties = AVAILABLE_PROPERTIES[self.model]
values = self.send(
"get_prop",
properties
@@ -216,52 +291,61 @@ def off(self):
@command(
click.argument("speed", type=int),
- default_output=format_output("Setting natural level to {level}")
+ default_output=format_output(
+ "Setting speed of the natural mode to {speed}")
)
- def set_natural_level(self, level: int):
+ def set_natural_speed(self, speed: int):
"""Set natural level."""
- level = max(0, min(level, 100))
- return self.send("set_natural_level", [level]) # 0...100
+ if speed < 0 or speed > 100:
+ raise FanException("Invalid speed: %s" % speed)
+
+ return self.send("set_natural_level", [speed])
@command(
click.argument("speed", type=int),
- default_output=format_output("Setting speed level to {level}")
+ default_output=format_output(
+ "Setting speed of the direct mode to {speed}")
)
- def set_speed_level(self, level: int):
- """Set speed level."""
- level = max(0, min(level, 100))
- return self.send("set_speed_level", [level]) # 0...100
+ def set_direct_speed(self, speed: int):
+ """Set speed of the direct mode."""
+ if speed < 0 or speed > 100:
+ raise FanException("Invalid speed: %s" % speed)
+
+ return self.send("set_speed_level", [speed])
@command(
click.argument("direction", type=EnumType(MoveDirection, False)),
default_output=format_output(
- "Setting move direction to {direction}")
+ "Rotating the fan to the {direction}")
)
- def set_direction(self, direction: MoveDirection):
- """Set move direction."""
+ def set_rotate(self, direction: MoveDirection):
+ """Rotate the fan by -5/+5 degrees left/right."""
return self.send("set_move", [direction.value])
@command(
click.argument("angle", type=int),
default_output=format_output("Setting angle to {angle}")
)
- def fan_set_angle(self, angle: int):
- """Set angle."""
- return self.send("set_angle", [angle])
+ def set_angle(self, angle: int):
+ """Set the oscillation angle."""
+ if angle < 0 or angle > 120:
+ raise FanException("Invalid angle: %s" % angle)
- @command(
- default_output=format_output("Turning on oscillate"),
- )
- def oscillate_on(self):
- """Enable oscillate."""
- return self.send("set_angle_enable", ["on"])
+ return self.send("set_angle", [angle])
@command(
- default_output=format_output("Turning off oscillate"),
+ click.argument("oscillate", type=bool),
+ default_output=format_output(
+ lambda lock: "Turning on oscillate"
+ if lock else "Turning off oscillate"
+ )
)
- def oscillate_off(self):
- """Disable oscillate."""
- return self.send("set_angle_enable", ["off"])
+ def set_oscillate(self, oscillate: bool):
+ """Set oscillate on/off."""
+ if oscillate:
+ return self.send("set_angle_enable", ["on"])
+ else:
+ return self.send("set_angle_enable", ["off"])
@command(
click.argument("brightness", type=EnumType(LedBrightness, False)),
@@ -273,29 +357,57 @@ def set_led_brightness(self, brightness: LedBrightness):
return self.send("set_led_b", [brightness.value])
@command(
- default_output=format_output("Turning on LED"),
+ click.argument("led", type=bool),
+ default_output=format_output(
+ lambda led: "Turning on LED"
+ if led else "Turning off LED"
+ )
)
- def led_on(self):
- """Turn led on."""
- return self.send("set_led", ["on"])
+ def set_led(self, led: bool):
+ """Turn led on/off."""
+ if led:
+ return self.send("set_led", ['on'])
+ else:
+ return self.send("set_led", ['off'])
@command(
- default_output=format_output("Turning off LED"),
+ click.argument("buzzer", type=bool),
+ default_output=format_output(
+ lambda buzzer: "Turning on buzzer"
+ if buzzer else "Turning off buzzer"
+ )
)
- def led_off(self):
- """Turn led off."""
- return self.send("set_led", ["off"])
+ def set_buzzer(self, buzzer: bool):
+ """Set buzzer on/off."""
+ if buzzer:
+ return self.send("set_buzzer", ["on"])
+ else:
+ return self.send("set_buzzer", ["off"])
@command(
- default_output=format_output("Turning on buzzer"),
+ click.argument("lock", type=bool),
+ default_output=format_output(
+ lambda lock: "Turning on child lock"
+ if lock else "Turning off child lock"
+ )
)
- def buzzer_on(self):
- """Enable buzzer."""
- return self.send("set_buzzer", ["on"])
+ def set_child_lock(self, lock: bool):
+ """Set child lock on/off."""
+ if lock:
+ return self.send("set_child_lock", ["on"])
+ else:
+ return self.send("set_child_lock", ["off"])
@command(
- default_output=format_output("Turning off buzzer"),
+ click.argument("seconds", type=int),
+ default_output=format_output(
+ "Setting delayed turn off to {seconds} seconds")
)
- def buzzer_off(self):
- """Disable buzzer."""
- return self.send("set_buzzer", ["off"])
+ def delay_off(self, seconds: int):
+ """Set delay off seconds."""
+
+ if seconds < 1:
+ raise FanException(
+ "Invalid value for a delayed turn off: %s" % seconds)
+
+ return self.send("set_poweroff_time", [seconds])
diff --git a/miio/tests/test_fan.py b/miio/tests/test_fan.py
new file mode 100644
index 000000000..ccb1ab5c3
--- /dev/null
+++ b/miio/tests/test_fan.py
@@ -0,0 +1,475 @@
+from unittest import TestCase
+
+import pytest
+
+from miio import Fan
+from miio.fan import (MoveDirection, LedBrightness, FanStatus, FanException,
+ MODEL_FAN_V2, MODEL_FAN_V3, )
+from .dummies import DummyDevice
+
+
+class DummyFanV2(DummyDevice, Fan):
+ def __init__(self, *args, **kwargs):
+ self.model = MODEL_FAN_V2
+ # This example response is just a guess. Please update!
+ self.state = {
+ 'temp_dec': 232,
+ 'humidity': 46,
+ 'angle': 118,
+ 'speed': 298,
+ 'poweroff_time': 0,
+ 'power': 'on',
+ 'ac_power': 'off',
+ 'battery': 98,
+ 'angle_enable': 'off',
+ 'speed_level': 1,
+ 'natural_level': 0,
+ 'child_lock': 'off',
+ 'buzzer': 'on',
+ 'led_b': 1,
+ 'led': 'on',
+ 'natural_enable': None,
+ 'use_time': 0,
+ 'bat_charge': 'complete',
+ 'bat_state': None,
+ 'button_pressed': 'speed'
+ }
+ self.return_values = {
+ 'get_prop': self._get_state,
+ 'set_power': lambda x: self._set_state("power", x),
+ 'set_speed_level': lambda x: self._set_state("speed_level", x),
+ 'set_natural_level': lambda x: self._set_state("natural_level", x),
+ 'set_move': lambda x: True,
+ 'set_angle': lambda x: self._set_state("angle", x),
+ 'set_angle_enable': lambda x: self._set_state("angle_enable", x),
+ 'set_led_b': lambda x: self._set_state("led_b", x),
+ 'set_led': lambda x: self._set_state("led", x),
+ 'set_buzzer': lambda x: self._set_state("buzzer", x),
+ 'set_child_lock': lambda x: self._set_state("child_lock", x),
+ 'set_poweroff_time': lambda x: self._set_state("poweroff_time", x),
+ }
+ super().__init__(args, kwargs)
+
+
+@pytest.fixture(scope="class")
+def fanv2(request):
+ request.cls.device = DummyFanV2()
+ # TODO add ability to test on a real device
+
+
+@pytest.mark.usefixtures("fanv2")
+class TestFanV2(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
+ assert self.is_on() 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 repr(self.state()) == repr(FanStatus(self.device.start_state))
+
+ assert self.is_on() is True
+ assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0
+ assert self.state().humidity == self.device.start_state["humidity"]
+ assert self.state().angle == self.device.start_state["angle"]
+ assert self.state().speed == self.device.start_state["speed"]
+ assert self.state().delay_off_countdown == self.device.start_state["poweroff_time"]
+ assert self.state().ac_power is (self.device.start_state["ac_power"] == 'on')
+ assert self.state().battery == self.device.start_state["battery"]
+ assert self.state().oscillate is (self.device.start_state["angle_enable"] == 'on')
+ assert self.state().direct_speed == self.device.start_state["speed_level"]
+ assert self.state().natural_speed == self.device.start_state["natural_level"]
+ assert self.state().child_lock is (self.device.start_state["child_lock"] == 'on')
+ assert self.state().buzzer is (self.device.start_state["buzzer"] == 'on')
+ assert self.state().led_brightness == LedBrightness(self.device.start_state["led_b"])
+ assert self.state().led is (self.device.start_state["led"] == "on")
+ assert self.state().use_time == self.device.start_state["use_time"]
+ assert self.state().battery_charge == self.device.start_state["bat_charge"]
+ assert self.state().battery_state == self.device.start_state["bat_state"]
+ assert self.state().button_pressed == self.device.start_state["button_pressed"]
+
+ def test_status_without_led_brightness(self):
+ self.device._reset_state()
+
+ self.device.state["led_b"] = None
+ assert self.state().led_brightness is None
+
+ def test_status_without_battery_charge(self):
+ self.device._reset_state()
+
+ self.device.state["bat_charge"] = None
+ assert self.state().battery_charge is None
+
+ def test_status_without_battery_state(self):
+ self.device._reset_state()
+
+ self.device.state["bat_state"] = None
+ assert self.state().battery_state is None
+
+ def test_status_without_button_pressed(self):
+ self.device._reset_state()
+
+ self.device.state["button_pressed"] = None
+ assert self.state().button_pressed is None
+
+ def test_set_led(self):
+ def led():
+ return self.device.status().led
+
+ self.device.set_led(True)
+ assert led() is True
+
+ self.device.set_led(False)
+ assert led() is False
+
+ def test_set_direct_speed(self):
+ def direct_speed():
+ return self.device.status().direct_speed
+
+ self.device.set_direct_speed(0)
+ assert direct_speed() == 0
+ self.device.set_direct_speed(1)
+ assert direct_speed() == 1
+ self.device.set_direct_speed(100)
+ assert direct_speed() == 100
+
+ with pytest.raises(FanException):
+ self.device.set_direct_speed(-1)
+
+ with pytest.raises(FanException):
+ self.device.set_direct_speed(101)
+
+ def test_set_rotate(self):
+ """The method is open-loop. The new state cannot be retrieved."""
+ self.device.set_rotate(MoveDirection.Left)
+ self.device.set_rotate(MoveDirection.Right)
+
+ def test_set_angle(self):
+ """This test doesn't implement the real behaviour of the device may be.
+
+ The property "angle" doesn't provide the current setting.
+ It's a measurement of the current position probably.
+ """
+ def angle():
+ return self.device.status().angle
+
+ self.device.set_angle(0) # TODO: Is this value allowed?
+ assert angle() == 0
+ self.device.set_angle(1) # TODO: Is this value allowed?
+ assert angle() == 1
+ self.device.set_angle(30)
+ assert angle() == 30
+ self.device.set_angle(60)
+ assert angle() == 60
+ self.device.set_angle(90)
+ assert angle() == 90
+ self.device.set_angle(120)
+ assert angle() == 120
+
+ with pytest.raises(FanException):
+ self.device.set_angle(-1)
+
+ with pytest.raises(FanException):
+ self.device.set_angle(121)
+
+ def test_set_oscillate(self):
+ def oscillate():
+ return self.device.status().oscillate
+
+ self.device.set_oscillate(True)
+ assert oscillate() is True
+
+ self.device.set_oscillate(False)
+ assert oscillate() is False
+
+ def test_set_led_brightness(self):
+ def led_brightness():
+ return self.device.status().led_brightness
+
+ self.device.set_led_brightness(LedBrightness.Bright)
+ assert led_brightness() == LedBrightness.Bright
+
+ self.device.set_led_brightness(LedBrightness.Dim)
+ assert led_brightness() == LedBrightness.Dim
+
+ self.device.set_led_brightness(LedBrightness.Off)
+ assert led_brightness() == LedBrightness.Off
+
+ def test_set_buzzer(self):
+ def buzzer():
+ return self.device.status().buzzer
+
+ self.device.set_buzzer(True)
+ assert buzzer() is True
+
+ self.device.set_buzzer(False)
+ assert buzzer() is False
+
+ def test_set_child_lock(self):
+ def child_lock():
+ return self.device.status().child_lock
+
+ self.device.set_child_lock(True)
+ assert child_lock() is True
+
+ self.device.set_child_lock(False)
+ assert child_lock() is False
+
+ def test_delay_off(self):
+ def delay_off_countdown():
+ return self.device.status().delay_off_countdown
+
+ self.device.delay_off(100)
+ assert delay_off_countdown() == 100
+ self.device.delay_off(200)
+ assert delay_off_countdown() == 200
+
+ with pytest.raises(FanException):
+ self.device.delay_off(-1)
+
+ with pytest.raises(FanException):
+ self.device.delay_off(0)
+
+
+class DummyFanV3(DummyDevice, Fan):
+ def __init__(self, *args, **kwargs):
+ self.model = MODEL_FAN_V3
+ self.state = {
+ 'temp_dec': 232,
+ 'humidity': 46,
+ 'angle': 118,
+ 'speed': 298,
+ 'poweroff_time': 0,
+ 'power': 'on',
+ 'ac_power': 'off',
+ 'battery': 98,
+ 'angle_enable': 'off',
+ 'speed_level': 1,
+ 'natural_level': 0,
+ 'child_lock': 'off',
+ 'buzzer': 'on',
+ 'led_b': 1,
+ 'led': None,
+ 'natural_enable': None,
+ 'use_time': 0,
+ 'bat_charge': 'complete',
+ 'bat_state': None,
+ 'button_pressed': 'speed'
+ }
+ self.return_values = {
+ 'get_prop': self._get_state,
+ 'set_power': lambda x: self._set_state("power", x),
+ 'set_speed_level': lambda x: self._set_state("speed_level", x),
+ 'set_natural_level': lambda x: self._set_state("natural_level", x),
+ 'set_move': lambda x: True,
+ 'set_angle': lambda x: self._set_state("angle", x),
+ 'set_angle_enable': lambda x: self._set_state("angle_enable", x),
+ 'set_led_b': lambda x: self._set_state("led_b", x),
+ 'set_led': lambda x: self._set_state("led", x),
+ 'set_buzzer': lambda x: self._set_state("buzzer", x),
+ 'set_child_lock': lambda x: self._set_state("child_lock", x),
+ 'set_poweroff_time': lambda x: self._set_state("poweroff_time", x),
+ }
+ super().__init__(args, kwargs)
+
+
+@pytest.fixture(scope="class")
+def fanv3(request):
+ request.cls.device = DummyFanV3()
+ # TODO add ability to test on a real device
+
+
+@pytest.mark.usefixtures("fanv3")
+class TestFanV3(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
+ assert self.is_on() 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 repr(self.state()) == repr(FanStatus(self.device.start_state))
+
+ assert self.is_on() is True
+ assert self.state().temperature == self.device.start_state["temp_dec"] / 10.0
+ assert self.state().humidity == self.device.start_state["humidity"]
+ assert self.state().angle == self.device.start_state["angle"]
+ assert self.state().speed == self.device.start_state["speed"]
+ assert self.state().delay_off_countdown == self.device.start_state["poweroff_time"]
+ assert self.state().ac_power is (self.device.start_state["ac_power"] == 'on')
+ assert self.state().battery == self.device.start_state["battery"]
+ assert self.state().oscillate is (self.device.start_state["angle_enable"] == 'on')
+ assert self.state().direct_speed == self.device.start_state["speed_level"]
+ assert self.state().natural_speed == self.device.start_state["natural_level"]
+ assert self.state().child_lock is (self.device.start_state["child_lock"] == 'on')
+ assert self.state().buzzer is (self.device.start_state["buzzer"] == 'on')
+ assert self.state().led_brightness == LedBrightness(self.device.start_state["led_b"])
+ assert self.state().led is None
+ assert self.state().use_time == self.device.start_state["use_time"]
+ assert self.state().battery_charge == self.device.start_state["bat_charge"]
+ assert self.state().battery_state == self.device.start_state["bat_state"]
+ assert self.state().button_pressed == self.device.start_state["button_pressed"]
+
+ def test_status_without_led_brightness(self):
+ self.device._reset_state()
+
+ self.device.state["led_b"] = None
+ assert self.state().led_brightness is None
+
+ def test_status_without_battery_charge(self):
+ self.device._reset_state()
+
+ self.device.state["bat_charge"] = None
+ assert self.state().battery_charge is None
+
+ def test_status_without_battery_state(self):
+ self.device._reset_state()
+
+ self.device.state["bat_state"] = None
+ assert self.state().battery_state is None
+
+ def test_status_without_button_pressed(self):
+ self.device._reset_state()
+
+ self.device.state["button_pressed"] = None
+ assert self.state().button_pressed is None
+
+ def test_set_direct_speed(self):
+ def direct_speed():
+ return self.device.status().direct_speed
+
+ self.device.set_direct_speed(0)
+ assert direct_speed() == 0
+ self.device.set_direct_speed(1)
+ assert direct_speed() == 1
+ self.device.set_direct_speed(100)
+ assert direct_speed() == 100
+
+ with pytest.raises(FanException):
+ self.device.set_direct_speed(-1)
+
+ with pytest.raises(FanException):
+ self.device.set_direct_speed(101)
+
+ def test_set_rotate(self):
+ """The method is open-loop. The new state cannot be retrieved."""
+ self.device.set_rotate(MoveDirection.Left)
+ self.device.set_rotate(MoveDirection.Right)
+
+ def test_set_angle(self):
+ """This test doesn't implement the real behaviour of the device may be.
+
+ The property "angle" doesn't provide the current setting.
+ It's a measurement of the current position probably.
+ """
+ def angle():
+ return self.device.status().angle
+
+ self.device.set_angle(0) # TODO: Is this value allowed?
+ assert angle() == 0
+ self.device.set_angle(1) # TODO: Is this value allowed?
+ assert angle() == 1
+ self.device.set_angle(30)
+ assert angle() == 30
+ self.device.set_angle(60)
+ assert angle() == 60
+ self.device.set_angle(90)
+ assert angle() == 90
+ self.device.set_angle(120)
+ assert angle() == 120
+
+ with pytest.raises(FanException):
+ self.device.set_angle(-1)
+
+ with pytest.raises(FanException):
+ self.device.set_angle(121)
+
+ def test_set_oscillate(self):
+ def oscillate():
+ return self.device.status().oscillate
+
+ self.device.set_oscillate(True)
+ assert oscillate() is True
+
+ self.device.set_oscillate(False)
+ assert oscillate() is False
+
+ def test_set_led_brightness(self):
+ def led_brightness():
+ return self.device.status().led_brightness
+
+ self.device.set_led_brightness(LedBrightness.Bright)
+ assert led_brightness() == LedBrightness.Bright
+
+ self.device.set_led_brightness(LedBrightness.Dim)
+ assert led_brightness() == LedBrightness.Dim
+
+ self.device.set_led_brightness(LedBrightness.Off)
+ assert led_brightness() == LedBrightness.Off
+
+ def test_set_buzzer(self):
+ def buzzer():
+ return self.device.status().buzzer
+
+ self.device.set_buzzer(True)
+ assert buzzer() is True
+
+ self.device.set_buzzer(False)
+ assert buzzer() is False
+
+ def test_set_child_lock(self):
+ def child_lock():
+ return self.device.status().child_lock
+
+ self.device.set_child_lock(True)
+ assert child_lock() is True
+
+ self.device.set_child_lock(False)
+ assert child_lock() is False
+
+ def test_delay_off(self):
+ def delay_off_countdown():
+ return self.device.status().delay_off_countdown
+
+ self.device.delay_off(100)
+ assert delay_off_countdown() == 100
+ self.device.delay_off(200)
+ assert delay_off_countdown() == 200
+
+ with pytest.raises(FanException):
+ self.device.delay_off(-1)
+
+ with pytest.raises(FanException):
+ self.device.delay_off(0)