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)