diff --git a/README.rst b/README.rst index 7ef7e88e2..c4c8af6e8 100644 --- a/README.rst +++ b/README.rst @@ -128,7 +128,7 @@ Supported devices - Xiaomi Universal IR Remote Controller (Chuangmi IR) - Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, ZA5 1C, P5, P9, P10, P11 - Xiaomi Rosou SS4 Ventilator (leshow.fan.ss4) -- Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ, JSQ1, JSQ001 +- Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ, JSQ1, JSQ001, JSQ002 - Xiaomi Mi Water Purifier (Basic support: Turn on & off) - Xiaomi Mi Water Purifier D1, C1 (Triple Setting) - Xiaomi PM2.5 Air Quality Monitor V1, B1, S1 diff --git a/miio/__init__.py b/miio/__init__.py index 790fcf1af..111cc074e 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -24,7 +24,7 @@ from miio.airfresh import AirFresh, AirFreshVA4 from miio.airfresh_t2017 import AirFreshA1, AirFreshT2017 from miio.airhumidifier import AirHumidifier, AirHumidifierCA1, AirHumidifierCB1 -from miio.airhumidifier_jsq import AirHumidifierJsq +from miio.airhumidifier_jsq import AirHumidifierJsq, AirHumidifierJsq002 from miio.airhumidifier_miot import AirHumidifierMiot from miio.airhumidifier_mjjsq import AirHumidifierMjjsq from miio.airpurifier import AirPurifier diff --git a/miio/airhumidifier_jsq.py b/miio/airhumidifier_jsq.py index 398f4aedf..737cb843c 100644 --- a/miio/airhumidifier_jsq.py +++ b/miio/airhumidifier_jsq.py @@ -12,20 +12,67 @@ # Xiaomi Zero Fog Humidifier MODEL_HUMIDIFIER_JSQ001 = "shuii.humidifier.jsq001" +# Xiaomi Zero Fog DWZF(G)-4500Z +MODEL_HUMIDIFIER_JSQ002 = "shuii.humidifier.jsq002" + +# Array of several common commands +SHARED_COMMANDS = { + MODEL_HUMIDIFIER_JSQ001: dict(power_on_off="set_start", buzzer_on_off="set_buzzer"), + MODEL_HUMIDIFIER_JSQ002: dict(power_on_off="on_off", buzzer_on_off="buzzer_on"), +} # Array of properties in same order as in humidifier response AVAILABLE_PROPERTIES = { MODEL_HUMIDIFIER_JSQ001: [ - "temperature", # (degrees, int) - "humidity", # (percentage, int) - "mode", # ( 0: Intelligent, 1: Level1, ..., 5:Level4) - "buzzer", # (0: off, 1: on) - "child_lock", # (0: off, 1: on) - "led_brightness", # (0: off, 1: low, 2: high) - "power", # (0: off, 1: on) - "no_water", # (0: enough, 1: add water) - "lid_opened", # (0: ok, 1: lid is opened) - ] + # Example of 'Air Humidifier (shuii.humidifier.jsq001)' response: + # [24, 37, 3, 1, 0, 2, 0, 0, 0] + "temperature", # 24 (degrees, int) + "humidity", # 37 (percentage, int) + "mode", # 3 ( 0: Intelligent, 1: Level1, ..., 5:Level4) + "buzzer", # 1 (0: off, 1: on) + "child_lock", # 0 (0: off, 1: on) + "led_brightness", # 2 (0: off, 1: low, 2: high) + "power", # 0 (0: off, 1: on) + "no_water", # 0 (water level state - 0: enough, 1: add water) + "lid_opened", # 0 (0: ok, 1: lid is opened) + ], + MODEL_HUMIDIFIER_JSQ002: [ + # Example of Xiaomi 'Zero Fog DWZF(G)-4500Z` (shuii.humidifier.jsq002)' + # Model: shuii.humidifier.jsq002 + # Hardware version: ESP8266 + # Firmware version: 1.4.0 + # + # > fast_set [bea, lock, temperature, humidity, led] + # 0/1, 0/1, 25, 50, 0/1] + # + # Properties: + # > dev.send("get_props","") + # [1, 2, 36, 2, 46, 4, 1, 1, 1, 50, 51, 0] + # res[0]=1 Values (0: off/sleep, 1: on); + "power", # CMD: on_off [int] + # res[1]=2 Values (1, 2, 3); fan speed in UI: {Gear: level 1, level 2, level 3}; + "mode", # CMD: set_gear [int] + # res[2]=36 Value (is % [int] ); Environment humidity; + "humidity", + # res[3]=2 Values (1, 2, 3) // Light 1 - Off; 2 - low; 3 - high + "led_brightness", # CMD: set_led [int] + # res[4]=26 Values(is "ambient temp degrees int"+20, i.e. 46 corresponds to 26); + "temperature", + # res[5]=4 (0,1,2,3,4,5). '5' is full, if pour more water, warning signal will sound + "water_level", # Get cmd: corrected_water [] + # res[6]=1, Water heater values (0: off, 1: on) + "heat", # CMD: warm_on [int] + # res[7]=1 BeaPower values (0: off, 1: on) + "buzzer", # CMD: buzzer_on [int] + # res[8]=1, Values (0: off, 1: on) + "child_lock", # CMD: set_lock [int] + # res[9]=50 Values (water heater target temp in degrees, [int]: 30..60) + "target_temperature", # CMD: set_temp [int] + # res[10]=51 Values (% [int]) + "target_humidity", # CMD: set_humidity [int] + # res[11]=0 Values: We failed to find when it changes, is not lid opened event + "reserved", + ], } @@ -43,19 +90,21 @@ class LedBrightness(enum.Enum): High = 2 -class AirHumidifierStatus(DeviceStatus): - """Container for status reports from the air humidifier jsq.""" +class OperationModeJsq002(enum.Enum): + Level1 = 1 + Level2 = 2 + Level3 = 3 - def __init__(self, data: Dict[str, Any]) -> None: - """Status of an Air Humidifier (shuii.humidifier.jsq001): - [24, 30, 1, 1, 0, 2, 0, 0, 0] +class LedBrightnessJsq002(enum.Enum): + Off = 1 + Low = 2 + High = 3 - Parsed by AirHumidifierJsq device as: - {'temperature': 24, 'humidity': 29, 'mode': 1, 'buzzer': 1, - 'child_lock': 0, 'led_brightness': 2, 'power': 0, 'no_water': 0, - 'lid_opened': 0} - """ + +class AirHumidifierStatusJsqCommon(DeviceStatus): + def __init__(self, data: Dict[str, Any]) -> None: + """Status of an Air Humidifier:""" self.data = data @property @@ -68,6 +117,30 @@ def is_on(self) -> bool: """True if device is turned on.""" return self.power == "on" + @property + def humidity(self) -> int: + """Current humidity in percent.""" + return self.data["humidity"] + + @property + def temperature(self) -> int: + """Current temperature in degree celsius.""" + return self.data["temperature"] + + @property + def buzzer(self) -> bool: + """True if buzzer is turned on.""" + return self.data["buzzer"] == 1 + + @property + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] == 1 + + +class AirHumidifierStatus(AirHumidifierStatusJsqCommon): + """Container for status reports from the air humidifier jsq.""" + @property def mode(self) -> OperationMode: """Operation mode. @@ -83,21 +156,6 @@ def mode(self) -> OperationMode: return mode - @property - def temperature(self) -> int: - """Current temperature in degree celsius.""" - return self.data["temperature"] - - @property - def humidity(self) -> int: - """Current humidity in percent.""" - return self.data["humidity"] - - @property - def buzzer(self) -> bool: - """True if buzzer is turned on.""" - return self.data["buzzer"] == 1 - @property def led_brightness(self) -> LedBrightness: """Buttons illumination Brightness level.""" @@ -114,11 +172,6 @@ def led(self) -> bool: """True if LED is turned on.""" return self.led_brightness is not LedBrightness.Off - @property - def child_lock(self) -> bool: - """Return True if child lock is on.""" - return self.data["child_lock"] == 1 - @property def no_water(self) -> bool: """True if the water tank is empty.""" @@ -138,46 +191,107 @@ def use_time(self) -> Optional[int]: return None -class AirHumidifierJsq(Device): - """Implementation of Xiaomi Zero Fog Humidifier: shuii.humidifier.jsq001.""" +class AirHumidifierStatusJsq002(AirHumidifierStatusJsqCommon): + @property + def mode(self) -> OperationModeJsq002: + """Operation mode. + + Can be either low, medium, high or humidity. + """ + + try: + mode = OperationModeJsq002(self.data["mode"]) + except ValueError as e: + _LOGGER.exception("Cannot parse mode: %s", e) + return OperationModeJsq002.Level1 + + return mode + + @property + def temperature(self) -> int: + """Current temperature in degree celsius.""" + + # Real temp is 'value - 20', were value from device + return self.data["temperature"] - 20 _supported_models = [MODEL_HUMIDIFIER_JSQ001] - @command( - default_output=format_output( - "", - "Power: {result.power}\n" - "Mode: {result.mode}\n" - "Temperature: {result.temperature} °C\n" - "Humidity: {result.humidity} %\n" - "Buzzer: {result.buzzer}\n" - "LED brightness: {result.led_brightness}\n" - "Child lock: {result.child_lock}\n" - "No water: {result.no_water}\n" - "Lid opened: {result.lid_opened}\n", - ) - ) - def status(self) -> AirHumidifierStatus: + @property + def water_level(self) -> int: + """Water level: 0,1,2,3,4,5.""" + + level = self.data["water_level"] + if level not in [0, 1, 2, 3, 4, 5]: + _LOGGER.exception( + "Water level should be 0,1,2,3,4,5. But was: %s", str(level) + ) + return 5 + + return level + + @property + def heater(self) -> bool: + """Return True if child lock is on.""" + return self.data["heat"] == 1 + + @property + def target_water_temperature(self) -> int: + """Return Target Water Temperature, degrees C, 30..60.""" + target_temp = self.data["target_temperature"] + target_temp_int = int(target_temp) + if target_temp_int not in range(30, 61): + _LOGGER.exception( + "Target water heater temp should be in [30..60]. But was: %s", + str(target_temp), + ) + return 30 + + return target_temp_int + + @property + def target_humidity(self) -> int: + """Return Target Water Temperature, degrees C, 30..60.""" + target_humidity = self.data["target_humidity"] + target_humidity_int = int(target_humidity) + if target_humidity_int not in range(0, 100): + _LOGGER.exception( + "Target humidity should be in [0..99]. But was: %s", + str(target_humidity), + ) + return 30 + + return target_humidity_int + + @property + def led_brightness(self) -> LedBrightnessJsq002: + """Buttons illumination Brightness level.""" + try: + brightness = LedBrightnessJsq002(self.data["led_brightness"]) + except ValueError as e: + _LOGGER.exception("Cannot parse brightness: %s", e) + return LedBrightnessJsq002.Off + + return brightness + + @property + def no_water(self) -> bool: + """True if the water tank is empty.""" + return self.water_level == 0 + + +class AirHumidifierJsqCommon(Device): + _supported_models = sorted(AVAILABLE_PROPERTIES.keys()) + + def _get_props(self) -> Dict: """Retrieve properties.""" values = self.send("get_props") - # Response of an Air Humidifier (shuii.humidifier.jsq001): - # [24, 37, 3, 1, 0, 2, 0, 0, 0] - # - # status[0] : temperature (degrees, int) - # status[1]: humidity (percentage, int) - # status[2]: mode ( 0: Intelligent, 1: Level1, ..., 5:Level4) - # status[3]: buzzer (0: off, 1: on) - # status[4]: lock (0: off, 1: on) - # status[5]: brightness (0: off, 1: low, 2: high) - # status[6]: power (0: off, 1: on) - # status[7]: water level state (0: ok, 1: add water) - # status[8]: lid state (0: ok, 1: lid is opened) - - properties = AVAILABLE_PROPERTIES.get( - self.model, AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_JSQ001] - ) + if self.model not in AVAILABLE_PROPERTIES: + raise AirHumidifierException("Unsupported model: %s" % self.model) + + properties = AVAILABLE_PROPERTIES[self.model] + if len(properties) != len(values): _LOGGER.error( "Count (%s) of requested properties (%s) does not match the " @@ -188,24 +302,85 @@ def status(self) -> AirHumidifierStatus: values, ) - return AirHumidifierStatus({k: v for k, v in zip(properties, values)}) + return {k: v for k, v in zip(properties, values)} @command(default_output=format_output("Powering on")) def on(self): """Power on.""" - return self.send("set_start", [1]) + if self.model not in SHARED_COMMANDS: + raise AirHumidifierException("Unsupported model: %s" % self.model) + + return self.send(SHARED_COMMANDS[self.model]["power_on_off"], [1]) @command(default_output=format_output("Powering off")) def off(self): """Power off.""" - return self.send("set_start", [0]) + if self.model not in SHARED_COMMANDS: + raise AirHumidifierException("Unsupported model: %s" % self.model) + + return self.send(SHARED_COMMANDS[self.model]["power_on_off"], [0]) + + @command( + click.argument("buzzer", type=bool), + default_output=format_output( + lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" + ), + ) + def set_buzzer(self, buzzer: bool): + """Set buzzer on/off. + + Supported args one of: true, false, 0, 1 + """ + if self.model not in SHARED_COMMANDS: + raise AirHumidifierException("Unsupported model: %s" % self.model) + + return self.send( + SHARED_COMMANDS[self.model]["buzzer_on_off"], [int(bool(buzzer))] + ) + + @command( + click.argument("lock", type=bool), + default_output=format_output( + lambda lock: "Turning on child lock" if lock else "Turning off child lock" + ), + ) + def set_child_lock(self, lock: bool): + """Set child lock on/off. + + Supported args one of: true, false, 0, 1 + """ + return self.send("set_lock", [int(bool(lock))]) + + +class AirHumidifierJsq(AirHumidifierJsqCommon): + """Implementation of Xiaomi Zero Fog Humidifier: shuii.humidifier.jsq001.""" + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Mode: {result.mode}\n" + "Temperature: {result.temperature} °C\n" + "Humidity: {result.humidity} %\n" + "Buzzer: {result.buzzer}\n" + "LED brightness: {result.led_brightness}\n" + "Child lock: {result.child_lock}\n" + "No water: {result.no_water}\n" + "Lid opened: {result.lid_opened}\n", + ) + ) + def status(self) -> AirHumidifierStatus: + return AirHumidifierStatus(self._get_props()) @command( click.argument("mode", type=EnumType(OperationMode)), default_output=format_output("Setting mode to '{mode.value}'"), ) def set_mode(self, mode: OperationMode): - """Set mode.""" + """Set mode. + + Supported args one of: 'intelligent', 'level1', 'level2', 'level3', 'level4' + """ value = mode.value if value not in (om.value for om in OperationMode): raise AirHumidifierException(f"{value} is not a valid OperationMode value") @@ -217,7 +392,10 @@ def set_mode(self, mode: OperationMode): default_output=format_output("Setting LED brightness to {brightness}"), ) def set_led_brightness(self, brightness: LedBrightness): - """Set led brightness.""" + """Set led brightness. + + Supported args one of: 'high', 'low', 'off'. + """ value = brightness.value if value not in (lb.value for lb in LedBrightness): raise AirHumidifierException(f"{value} is not a valid LedBrightness value") @@ -231,26 +409,141 @@ def set_led_brightness(self, brightness: LedBrightness): ), ) def set_led(self, led: bool): - """Turn led on/off.""" + """Turn led on/off. + + Supported args one of: true, false, 0, 1 + """ brightness = LedBrightness.High if led else LedBrightness.Off return self.set_led_brightness(brightness) + +class AirHumidifierJsq002(AirHumidifierJsqCommon): + """Implementation of Xiaomi Zero Fog DWZF(G)-4500Z: shuii.humidifier.jsq002.""" + @command( - click.argument("buzzer", type=bool), default_output=format_output( - lambda buzzer: "Turning on buzzer" if buzzer else "Turning off buzzer" + "", + "Power: {result.power}\n" + "Mode: {result.mode}\n" + "Temperature: {result.temperature} °C\n" + "Humidity: {result.humidity} %\n" + "Buzzer: {result.buzzer}\n" + "LED brightness: {result.led_brightness}\n" + "Child lock: {result.child_lock}\n" + "Water level: {result.water_level}\n" + "Water heater: {result.heater}\n" + "Water target temperature: {result.target_water_temperature} °C\n" + "Target humidity: {result.target_humidity} %\n", + ) + ) + def status(self) -> AirHumidifierStatusJsq002: + return AirHumidifierStatusJsq002(self._get_props()) + + @command( + click.argument("mode", type=EnumType(OperationModeJsq002)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationModeJsq002): + """Set mode. + + Supported args one of: 'level1', 'level2', 'level3' + """ + value = mode.value + if value not in (om.value for om in OperationModeJsq002): + raise AirHumidifierException( + f"{value} is not a valid OperationModeJsq2 value" + ) + + return self.send("set_gear", [value]) + + @command( + click.argument("brightness", type=EnumType(LedBrightnessJsq002)), + default_output=format_output("Setting LED brightness to {brightness}"), + ) + def set_led_brightness(self, brightness: LedBrightnessJsq002): + """Set led brightness. + + Supported args one of: 'high', 'low', 'off'. + """ + value = brightness.value + if value not in (lb.value for lb in LedBrightnessJsq002): + raise AirHumidifierException( + f"{value} is not a valid LedBrightnessJsq2 value" + ) + + return self.send("set_led", [value]) + + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" if led else "Turning off LED" ), ) - def set_buzzer(self, buzzer: bool): - """Set buzzer on/off.""" - return self.send("set_buzzer", [int(bool(buzzer))]) + def set_led(self, led: bool): + """Turn led on/off. + + Supported args one of: true, false, 0, 1 + """ + brightness = LedBrightnessJsq002.High if led else LedBrightnessJsq002.Off + return self.set_led_brightness(brightness) @command( - click.argument("lock", type=bool), + click.argument("heater", type=bool), default_output=format_output( - lambda lock: "Turning on child lock" if lock else "Turning off child lock" + lambda heater: "Turning on water heater" + if heater + else "Turning off water heater" ), ) - def set_child_lock(self, lock: bool): - """Set child lock on/off.""" - return self.send("set_lock", [int(bool(lock))]) + def set_heater(self, heater: bool): + """Set water heater on/off. + + Supported args one of: true, false, 0, 1 + """ + return self.send("warm_on", [int(bool(heater))]) + + @command( + click.argument("temperature", type=int), + default_output=format_output( + "Setting target water temperature to {temperature}" + ), + ) + def set_target_water_temperature(self, temperature: int): + """Set the target water temperature degrees C, supported integer numbers in + range 30..60.""" + + if temperature not in range(30, 61): + raise AirHumidifierException( + "Invalid water target temperature, should be in [30..60]. But was: %s" + % temperature + ) + + return self.send("set_temp", [temperature]) + + @command( + click.argument("humidity", type=int), + default_output=format_output("Setting target humidity to {humidity}"), + ) + def set_target_humidity(self, humidity: int): + """Set the target humidity %, supported integer numbers in range 0..99.""" + + if humidity not in range(0, 100): + raise AirHumidifierException( + "Invalid target humidity, should be in [0..99]. But was: %s" % humidity + ) + + return self.send("set_humidity", [humidity]) + + @command( + default_output=format_output("Running `rst_clean` command"), + ) + def rst_clean(self): + """Run 'rst_clean' command (unknown function).""" + return self.send("rst_clean", []) + + @command( + default_output=format_output("Calibrating water level as zero"), + ) + def corrected_water(self): + """Calibrate current water level as zero level.""" + return self.send("corrected_water", []) diff --git a/miio/discovery.py b/miio/discovery.py index 5a30fdd51..91cba7484 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -21,6 +21,7 @@ AirFreshT2017, AirHumidifier, AirHumidifierJsq, + AirHumidifierJsq002, AirHumidifierJsqs, AirHumidifierMjjsq, AirPurifier, @@ -62,6 +63,7 @@ MODEL_HUMIDIFIER_CB1, MODEL_HUMIDIFIER_V1, ) +from .airhumidifier_jsq import MODEL_HUMIDIFIER_JSQ001, MODEL_HUMIDIFIER_JSQ002 from .airhumidifier_mjjsq import MODEL_HUMIDIFIER_JSQ1, MODEL_HUMIDIFIER_MJJSQ from .airqualitymonitor import ( MODEL_AIRQUALITYMONITOR_B1, @@ -133,7 +135,10 @@ "zhimi-humidifier-v1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_V1), "zhimi-humidifier-ca1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_CA1), "zhimi-humidifier-cb1": partial(AirHumidifier, model=MODEL_HUMIDIFIER_CB1), - "shuii-humidifier-jsq001": partial(AirHumidifierJsq, model=MODEL_HUMIDIFIER_MJJSQ), + "shuii-humidifier-jsq001": partial(AirHumidifierJsq, model=MODEL_HUMIDIFIER_JSQ001), + "shuii-humidifier-jsq002": partial( + AirHumidifierJsq002, model=MODEL_HUMIDIFIER_JSQ002 + ), "deerma-humidifier-mjjsq": partial( AirHumidifierMjjsq, model=MODEL_HUMIDIFIER_MJJSQ ), diff --git a/miio/tests/test_airhumidifier_jsq.py b/miio/tests/test_airhumidifier_jsq.py index 60f3e2536..d048f4f2b 100644 --- a/miio/tests/test_airhumidifier_jsq.py +++ b/miio/tests/test_airhumidifier_jsq.py @@ -1,21 +1,25 @@ -from collections import OrderedDict from unittest import TestCase import pytest -from miio import AirHumidifierJsq +from miio import AirHumidifierJsq, AirHumidifierJsq002 from miio.airhumidifier import AirHumidifierException from miio.airhumidifier_jsq import ( + AVAILABLE_PROPERTIES, MODEL_HUMIDIFIER_JSQ001, + MODEL_HUMIDIFIER_JSQ002, AirHumidifierStatus, + AirHumidifierStatusJsq002, LedBrightness, + LedBrightnessJsq002, OperationMode, + OperationModeJsq002, ) from .dummies import DummyDevice -class DummyAirHumidifierJsq(DummyDevice, AirHumidifierJsq): +class DummyAirHumidifierJsq001(DummyDevice, AirHumidifierJsq): def __init__(self, *args, **kwargs): self._model = MODEL_HUMIDIFIER_JSQ001 @@ -40,46 +44,94 @@ def __init__(self, *args, **kwargs): self.device_info = None - self.state = OrderedDict( - ( - ("temperature", 24), - ("humidity", 29), - ("mode", 3), - ("buzzer", 1), - ("child_lock", 1), - ("led_brightness", 2), - ("power", 1), - ("no_water", 1), - ("lid_opened", 1), - ) - ) - self.start_state = self.state.copy() + self.start_state = [24, 29, 3, 1, 1, 2, 1, 1, 1] + self.state = self.start_state.copy() + + # Mocks for `device.send("cmd_name", args)` commands: + self._available_properties = AVAILABLE_PROPERTIES[self._model] self.return_values = { "get_props": self._get_state, - "set_start": lambda x: self._set_state("power", x), - "set_mode": lambda x: self._set_state("mode", x), - "set_brightness": lambda x: self._set_state("led_brightness", x), - "set_buzzer": lambda x: self._set_state("buzzer", x), - "set_lock": lambda x: self._set_state("child_lock", x), + "set_start": lambda x: self._set_state_by_key("power", x), + "set_mode": lambda x: self._set_state_by_key("mode", x), + "set_brightness": lambda x: self._set_state_by_key("led_brightness", x), + "set_buzzer": lambda x: self._set_state_by_key("buzzer", x), + "set_lock": lambda x: self._set_state_by_key("child_lock", x), "miIO.info": self._get_device_info, } super().__init__(args, kwargs) + def _set_state_by_key(self, key, value): + self._set_state(self._available_properties.index(key), value) + + def _get_state_by_key(self, key): + return self.state[self._available_properties.index(key)] + def _get_device_info(self, _): """Return dummy device info.""" return self.dummy_device_info def _get_state(self, props): - """Return wanted properties.""" - return list(self.state.values()) + """Mocks device `get_props` command.""" + return self.state + + +class DummyAirHumidifierJsq002(DummyDevice, AirHumidifierJsq002): + def __init__(self, *args, **kwargs): + self._model = MODEL_HUMIDIFIER_JSQ002 + + self.dummy_device_info = { + "fw_ver": "1.4.0", + "hw_ver": "ESP8266", + } + + self.start_state = [1, 2, 36, 2, 46, 4, 1, 1, 1, 50, 51, 0] + self.state = self.start_state.copy() + + # Mocks for `device.send("cmd_name", args)` commands: + self._available_properties = AVAILABLE_PROPERTIES[self._model] + + self.return_values = { + "get_props": self._get_state, + "on_off": lambda x: self._set_state_by_key("power", x), + "set_gear": lambda x: self._set_state_by_key("mode", x), + "set_led": lambda x: self._set_state_by_key("led_brightness", x), + "warm_on": lambda x: self._set_state_by_key("heat", x), + "buzzer_on": lambda x: self._set_state_by_key("buzzer", x), + "set_lock": lambda x: self._set_state_by_key("child_lock", x), + "set_temp": lambda x: self._set_state_by_key("target_temperature", x), + "set_humidity": lambda x: self._set_state_by_key("target_humidity", x), + # "corrected_water": ? + # "rst_clean": ? + "miIO.info": self._get_device_info, + } + + super().__init__(args, kwargs) + + def _set_state_by_key(self, key, value): + self._set_state(self._available_properties.index(key), value) + + def _get_state_by_key(self, key): + return self.state[self._available_properties.index(key)] + + def _get_state(self, props): + """Mocks device `get_props` command.""" + return self.state + + def _get_device_info(self, _): + """Return dummy device info.""" + return self.dummy_device_info + + +@pytest.fixture(scope="class") +def airhumidifier_jsq001(request): + request.cls.device = DummyAirHumidifierJsq001() @pytest.fixture(scope="class") -def airhumidifier_jsq(request): - request.cls.device = DummyAirHumidifierJsq() - # TODO add ability to test on a real device +def airhumidifier_jsq002(request): + request.cls.device = DummyAirHumidifierJsq002() class Bunch: @@ -87,44 +139,82 @@ def __init__(self, **kwds): self.__dict__.update(kwds) -@pytest.mark.usefixtures("airhumidifier_jsq") -class TestAirHumidifierJsq(TestCase): - def is_on(self): - return self.device.status().is_on - +class AirHumidifierJsqTestCase(TestCase): def state(self): return self.device.status() + def _is_on(self): + return self.device.status().is_on + + def do_test_on_off_property(self, setter_name, getter_name): + def prop_value(): + return getattr(self.device.status(), getter_name) + + prop_fun = getattr(self.device, setter_name) + prop_fun(True) + assert prop_value() is True + + prop_fun(False) + assert prop_value() is False + + # if user uses wrong type for buzzer value + prop_fun(1) + assert prop_value() is True + + prop_fun(0) + assert prop_value() is False + + prop_fun("not_empty_str") + assert prop_value() is True + + prop_fun("on") + assert prop_value() is True + + # all string values are considered to by True, even "off" + prop_fun("off") + assert prop_value() is True + + prop_fun("") + assert prop_value() is False + + +@pytest.mark.usefixtures("airhumidifier_jsq001") +class TestAirHumidifierJsq001(AirHumidifierJsqTestCase): def test_on(self): self.device.off() # ensure off - assert self.is_on() is False + assert self._is_on() is False self.device.on() - assert self.is_on() is True + assert self._is_on() is True def test_off(self): self.device.on() # ensure on - assert self.is_on() is True + assert self._is_on() is True self.device.off() - assert self.is_on() is False + assert self._is_on() is False def test_status(self): self.device._reset_state() - assert repr(self.state()) == repr(AirHumidifierStatus(self.device.start_state)) - - assert self.state().temperature == self.device.start_state["temperature"] - assert self.state().humidity == self.device.start_state["humidity"] - assert self.state().mode == OperationMode(self.device.start_state["mode"]) - assert self.state().buzzer == (self.device.start_state["buzzer"] == 1) - assert self.state().child_lock == (self.device.start_state["child_lock"] == 1) - assert self.state().led_brightness == LedBrightness( - self.device.start_state["led_brightness"] + state: AirHumidifierStatus = self.state() + properties = AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_JSQ001] + assert repr(state) == repr( + AirHumidifierStatus( + {k: v for k, v in zip(properties, self.device.start_state)} + ) ) - assert self.is_on() is True - assert self.state().no_water == (self.device.start_state["no_water"] == 1) - assert self.state().lid_opened == (self.device.start_state["lid_opened"] == 1) + + assert state.data["temperature"] == 24 + assert state.data["humidity"] == 29 + assert state.data["mode"] == 3 + assert state.data["buzzer"] == 1 + assert state.data["child_lock"] == 1 + assert state.data["led_brightness"] == 2 + assert self._is_on() is True + assert state.data["power"] == 1 + assert state.data["no_water"] == 1 + assert state.data["lid_opened"] == 1 def test_status_wrong_input(self): def mode(): @@ -135,16 +225,16 @@ def led_brightness(): self.device._reset_state() - self.device.state["mode"] = 10 + self.device._set_state_by_key("mode", [10]) assert mode() == OperationMode.Intelligent - self.device.state["mode"] = "smth" + self.device._set_state_by_key("mode", ["smt"]) assert mode() == OperationMode.Intelligent - self.device.state["led_brightness"] = 10 + self.device._set_state_by_key("led_brightness", [10]) assert led_brightness() == LedBrightness.Off - self.device.state["led_brightness"] = "smth" + self.device._set_state_by_key("led_brightness", ["smt"]) assert led_brightness() == LedBrightness.Off def test_set_mode(self): @@ -228,79 +318,336 @@ def led_brightness(): assert led_brightness() == LedBrightness.Off def test_set_buzzer(self): - def buzzer(): - return self.device.status().buzzer + self.do_test_on_off_property("set_buzzer", "buzzer") - self.device.set_buzzer(True) - assert buzzer() is True + def test_status_without_temperature(self): + self.device._reset_state() + self.device._set_state_by_key("temperature", [None]) - self.device.set_buzzer(False) - assert buzzer() is False + assert self.state().temperature is None - # if user uses wrong type for buzzer value - self.device.set_buzzer(1) - assert buzzer() is True + def test_status_without_led_brightness(self): + self.device._reset_state() - self.device.set_buzzer(0) - assert buzzer() is False + self.device._set_state_by_key("led_brightness", [None]) - self.device.set_buzzer("not_empty_str") - assert buzzer() is True + assert self.state().led_brightness is LedBrightness.Off - self.device.set_buzzer("on") - assert buzzer() is True + def test_status_without_mode(self): + self.device._reset_state() - # all string values are considered to by True, even "off" - self.device.set_buzzer("off") - assert buzzer() is True + self.device._set_state_by_key("mode", [None]) - self.device.set_buzzer("") - assert buzzer() is False + assert self.state().mode is OperationMode.Intelligent - def test_status_without_temperature(self): - self.device._reset_state() - self.device.state["temperature"] = None + def test_set_child_lock(self): + self.do_test_on_off_property("set_child_lock", "child_lock") - assert self.state().temperature is None - def test_status_without_led_brightness(self): +@pytest.mark.usefixtures("airhumidifier_jsq002") +class TestAirHumidifierJsq002(AirHumidifierJsqTestCase): + def test_status(self): self.device._reset_state() - self.device.state["led_brightness"] = None - assert self.state().led_brightness is LedBrightness.Off + state: AirHumidifierStatusJsq002 = self.state() + properties = AVAILABLE_PROPERTIES[MODEL_HUMIDIFIER_JSQ002] + assert repr(state) == repr( + AirHumidifierStatusJsq002( + {k: v for k, v in zip(properties, self.device.start_state)} + ) + ) + + assert state.data["power"] == 1 + assert self._is_on() is True + + assert state.data["mode"] == 2 + assert state.mode == OperationModeJsq002.Level2 + + assert state.data["humidity"] == 36 + assert state.humidity == 36 + + assert state.data["led_brightness"] == 2 + assert state.led_brightness == LedBrightnessJsq002.Low + + assert state.data["temperature"] == 46 + assert state.temperature == 26 + + assert state.data["water_level"] == 4 + assert state.water_level == 4 + + assert state.data["heat"] == 1 + assert state.heater is True + + assert state.data["buzzer"] == 1 + assert state.buzzer is True + + assert state.data["child_lock"] == 1 + assert state.child_lock is True + + assert state.data["target_temperature"] == 50 + assert state.target_water_temperature == 50 + + assert state.data["target_humidity"] == 51 + assert state.target_humidity == 51 + + assert state.data["reserved"] == 0 + + 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_wrong_input(self): + def mode(): + return self.device.status().mode + + def set_mock_state_mode(new_mode): + self.device._set_state_by_key("mode", [new_mode]) + + def set_mock_state_brightness(new_val): + self.device._set_state_by_key("led_brightness", [new_val]) + + def led_brightness(): + return self.device.status().led_brightness + + def water_level(): + return self.device.status().water_level + + def set_mock_water_level(new_val): + self.device._set_state_by_key("water_level", [new_val]) - def test_status_without_mode(self): self.device._reset_state() - self.device.state["mode"] = None - assert self.state().mode is OperationMode.Intelligent + set_mock_state_mode(10) + assert mode() == OperationModeJsq002.Level1 + + set_mock_state_mode(10) + assert mode() == OperationModeJsq002.Level1 + + set_mock_state_brightness(10) + assert led_brightness() == LedBrightnessJsq002.Off + + set_mock_state_brightness("smth") + assert led_brightness() == LedBrightnessJsq002.Off + + set_mock_water_level(10) + assert water_level() == 5 + + set_mock_water_level("smth") + assert water_level() == 5 + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationModeJsq002.Level1) + assert mode() == OperationModeJsq002.Level1 + + self.device.set_mode(OperationModeJsq002.Level2) + assert mode() == OperationModeJsq002.Level2 + + self.device.set_mode(OperationModeJsq002.Level3) + assert mode() == OperationModeJsq002.Level3 + + def test_set_mode_wrong_input(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationModeJsq002.Level3) + assert mode() == OperationModeJsq002.Level3 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_mode(Bunch(value=10)) + assert str(excinfo.value) == "10 is not a valid OperationModeJsq2 value" + assert mode() == OperationModeJsq002.Level3 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_mode(Bunch(value=-1)) + assert str(excinfo.value) == "-1 is not a valid OperationModeJsq2 value" + assert mode() == OperationModeJsq002.Level3 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_mode(Bunch(value="smth")) + assert str(excinfo.value) == "smth is not a valid OperationModeJsq2 value" + assert mode() == OperationModeJsq002.Level3 + + def test_set_led_brightness(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led_brightness(LedBrightnessJsq002.Off) + assert led_brightness() == LedBrightnessJsq002.Off + + self.device.set_led_brightness(LedBrightnessJsq002.Low) + assert led_brightness() == LedBrightnessJsq002.Low + + self.device.set_led_brightness(LedBrightnessJsq002.High) + assert led_brightness() == LedBrightnessJsq002.High + + def test_set_led_brightness_wrong_input(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led_brightness(LedBrightnessJsq002.Low) + assert led_brightness() == LedBrightnessJsq002.Low + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_led_brightness(Bunch(value=10)) + assert str(excinfo.value) == "10 is not a valid LedBrightnessJsq2 value" + assert led_brightness() == LedBrightnessJsq002.Low + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_led_brightness(Bunch(value=-10)) + assert str(excinfo.value) == "-10 is not a valid LedBrightnessJsq2 value" + assert led_brightness() == LedBrightnessJsq002.Low + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_led_brightness(Bunch(value="smth")) + assert str(excinfo.value) == "smth is not a valid LedBrightnessJsq2 value" + assert led_brightness() == LedBrightnessJsq002.Low + + def test_set_led(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led(True) + assert led_brightness() == LedBrightnessJsq002.High + + self.device.set_led(False) + assert led_brightness() == LedBrightnessJsq002.Off + + def test_no_water(self): + def set_mock_water_level(new_val): + self.device._set_state_by_key("water_level", [new_val]) + + def no_water(): + return self.device.status().no_water + + set_mock_water_level(1) + assert no_water() is False + + set_mock_water_level(0) + assert no_water() is True + + set_mock_water_level(6) + assert no_water() is False + + def test_set_heater(self): + self.do_test_on_off_property("set_heater", "heater") + + def test_set_buzzer(self): + self.do_test_on_off_property("set_buzzer", "buzzer") def test_set_child_lock(self): - def child_lock(): - return self.device.status().child_lock + self.do_test_on_off_property("set_child_lock", "child_lock") - self.device.set_child_lock(True) - assert child_lock() is True + def test_set_target_water_temperature(self): + def water_target_temp(): + return self.device.status().target_water_temperature - self.device.set_child_lock(False) - assert child_lock() is False + self.device.set_target_water_temperature(30) + assert water_target_temp() == 30 - # if user uses wrong type for buzzer value - self.device.set_child_lock(1) - assert child_lock() is True + self.device.set_target_water_temperature(31) + assert water_target_temp() == 31 - self.device.set_child_lock(0) - assert child_lock() is False + self.device.set_target_water_temperature(59) + assert water_target_temp() == 59 - self.device.set_child_lock("not_empty_str") - assert child_lock() is True + self.device.set_target_water_temperature(60) + assert water_target_temp() == 60 - self.device.set_child_lock("on") - assert child_lock() is True + def test_set_target_water_temperature_wrong_input(self): + def water_target_temp(): + return self.device.status().target_water_temperature - # all string values are considered to by True, even "off" - self.device.set_child_lock("off") - assert child_lock() is True + self.device.set_target_water_temperature(40) + assert water_target_temp() == 40 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_water_temperature(29) + assert ( + str(excinfo.value) + == "Invalid water target temperature, should be in [30..60]. But was: 29" + ) + assert water_target_temp() == 40 - self.device.set_child_lock("") - assert child_lock() is False + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_water_temperature(61) + assert ( + str(excinfo.value) + == "Invalid water target temperature, should be in [30..60]. But was: 61" + ) + assert water_target_temp() == 40 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_water_temperature(-10) + assert ( + str(excinfo.value) + == "Invalid water target temperature, should be in [30..60]. But was: -10" + ) + assert water_target_temp() == 40 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_water_temperature("smth") + assert ( + str(excinfo.value) + == "Invalid water target temperature, should be in [30..60]. But was: smth" + ) + assert water_target_temp() == 40 + + def test_set_target_humidity(self): + def target_humidity(): + return self.device.status().target_humidity + + self.device.set_target_humidity(0) + assert target_humidity() == 0 + + self.device.set_target_humidity(31) + assert target_humidity() == 31 + + self.device.set_target_humidity(99) + assert target_humidity() == 99 + + # self.device.set_target_humidity(160) + # assert target_humidity() == 160 + + def test_set_target_humidity_wrong_input(self): + def target_humidity(): + return self.device.status().target_humidity + + self.device.set_target_humidity(40) + assert target_humidity() == 40 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_humidity(-10) + assert ( + str(excinfo.value) + == "Invalid target humidity, should be in [0..99]. But was: -10" + ) + assert target_humidity() == 40 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_humidity(100) + assert ( + str(excinfo.value) + == "Invalid target humidity, should be in [0..99]. But was: 100" + ) + assert target_humidity() == 40 + + with pytest.raises(AirHumidifierException) as excinfo: + self.device.set_target_humidity("smth") + assert ( + str(excinfo.value) + == "Invalid target humidity, should be in [0..99]. But was: smth" + ) + assert target_humidity() == 40