diff --git a/README.rst b/README.rst index 3afc10bb7..9997cd5eb 100644 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ Supported devices - Xiaomi Smart WiFi Speaker - Xiaomi Mi WiFi Repeater 2 - Xiaomi Mi Smart Rice Cooker -- Xiaomi Smartmi Fresh Air System +- Xiaomi Smartmi Fresh Air System VA2 (zhimi.airfresh.va2), T2017 (dmaker.airfresh.t2017) - Yeelight lights (basic support, we recommend using `python-yeelight `__) - Xiaomi Mi Air Dehumidifier - Xiaomi Tinymu Smart Toilet Cover diff --git a/miio/__init__.py b/miio/__init__.py index 7909bf810..837e357be 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -5,6 +5,7 @@ ) from miio.airdehumidifier import AirDehumidifier from miio.airfresh import AirFresh +from miio.airfresh_t2017 import AirFreshT2017 from miio.airhumidifier import AirHumidifier, AirHumidifierCA1, AirHumidifierCB1 from miio.airhumidifier_mjjsq import AirHumidifierMjjsq from miio.airpurifier import AirPurifier diff --git a/miio/airfresh_t2017.py b/miio/airfresh_t2017.py new file mode 100644 index 000000000..d64c95ea3 --- /dev/null +++ b/miio/airfresh_t2017.py @@ -0,0 +1,418 @@ +import enum +import logging +from collections import defaultdict +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .device import Device, DeviceException + +_LOGGER = logging.getLogger(__name__) + +MODEL_AIRFRESH_T2017 = "dmaker.airfresh.t2017" + +AVAILABLE_PROPERTIES = { + MODEL_AIRFRESH_T2017: [ + "power", + "mode", + "pm25", + "co2", + "temperature_outside", + "favourite_speed", + "control_speed", + "filter_intermediate", + "filter_inter_day", + "filter_efficient", + "filter_effi_day", + "ptc_on", + "ptc_level", + "ptc_status", + "child_lock", + "sound", + "display", + "screen_direction", + ] +} + + +class AirFreshException(DeviceException): + pass + + +class OperationMode(enum.Enum): + Off = "off" + Auto = "auto" + Sleep = "sleep" + Favorite = "favourite" + + +class PtcLevel(enum.Enum): + Off = "off" + Low = "low" + Medium = "medium" + High = "high" + + +class DisplayOrientation(enum.Enum): + Portrait = "forward" + LandscapeLeft = "left" + LandscapeRight = "right" + + +class AirFreshStatus: + """Container for status reports from the air fresh t2017.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response of a Air Airfresh T2017 (dmaker.airfresh.t2017): + + { + 'power': true, + 'mode': "favourite", + 'pm25': 1, + 'co2': 550, + 'temperature_outside': 24, + 'favourite_speed': 241, + 'control_speed': 241, + 'filter_intermediate': 100, + 'filter_inter_day': 90, + 'filter_efficient': 100, + 'filter_effi_day': 180, + 'ptc_on': false, + 'ptc_level': "low", + 'ptc_status': false, + 'child_lock': false, + 'sound': true, + 'display': false, + 'screen_direction': "forward", + } + """ + + self.data = data + + @property + def power(self) -> str: + """Power state.""" + return "on" if self.data["power"] else "off" + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.data["power"] + + @property + def mode(self) -> OperationMode: + """Current operation mode.""" + return OperationMode(self.data["mode"]) + + @property + def pm25(self) -> int: + """Fine particulate patter (PM2.5).""" + return self.data["pm25"] + + @property + def co2(self) -> int: + """Carbon dioxide.""" + return self.data["co2"] + + @property + def temperature(self) -> int: + """Current temperature in degree celsions.""" + return self.data["temperature_outside"] + + @property + def favorite_speed(self) -> int: + """Favorite speed.""" + return self.data["favourite_speed"] + + @property + def control_speed(self) -> int: + """Control speed.""" + return self.data["control_speed"] + + @property + def dust_filter_life_remaining(self) -> int: + """Remaining dust filter life in percent.""" + return self.data["filter_intermediate"] + + @property + def dust_filter_life_remaining_days(self) -> int: + """Remaining dust filter life in days.""" + return self.data["filter_inter_day"] + + @property + def upper_filter_life_remaining(self) -> int: + """Remaining upper filter life in percent.""" + return self.data["filter_efficient"] + + @property + def upper_filter_life_remaining_days(self) -> int: + """Remaining upper filter life in days.""" + return self.data["filter_effi_day"] + + @property + def ptc(self) -> bool: + """Return True if PTC is on.""" + return self.data["ptc_on"] + + @property + def ptc_level(self) -> int: + """PTC level.""" + return PtcLevel(self.data["ptc_level"]) + + @property + def ptc_status(self) -> bool: + """Return true if PTC status is on.""" + return self.data["ptc_status"] + + @property + def child_lock(self) -> bool: + """Return True if child lock is on.""" + return self.data["child_lock"] + + @property + def buzzer(self) -> bool: + """Return True if sound is on.""" + return self.data["sound"] + + @property + def display(self) -> bool: + """Return True if the display is on.""" + return self.data["display"] + + @property + def display_orientation(self) -> int: + """Display orientation.""" + return DisplayOrientation(self.data["screen_direction"]) + + def __repr__(self) -> str: + s = ( + "" + % ( + self.power, + self.mode, + self.pm25, + self.co2, + self.temperature, + self.favorite_speed, + self.control_speed, + self.dust_filter_life_remaining, + self.dust_filter_life_remaining_days, + self.upper_filter_life_remaining, + self.upper_filter_life_remaining_days, + self.ptc, + self.ptc_level, + self.ptc_status, + self.child_lock, + self.buzzer, + self.display, + self.display_orientation, + ) + ) + return s + + def __json__(self): + return self.data + + +class AirFreshT2017(Device): + """Main class representing the air fresh t2017.""" + + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + model: str = MODEL_AIRFRESH_T2017, + ) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover) + + if model in AVAILABLE_PROPERTIES: + self.model = model + else: + self.model = MODEL_AIRFRESH_T2017 + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Mode: {result.mode}\n" + "PM2.5: {result.pm25}\n" + "CO2: {result.co2}\n" + "Temperature: {result.temperature}\n" + "Favorite speed: {result.favorite_speed}\n" + "Control speed: {result.control_speed}\n" + "Dust filter life: {result.dust_filter_life_remaining} %, " + "{result.dust_filter_life_remaining_days} days\n" + "Upper filter life remaining: {result.upper_filter_life_remaining} %, " + "{result.upper_filter_life_remaining_days} days\n" + "PTC: {result.ptc}\n" + "PTC level: {result.ptc_level}\n" + "PTC status: {result.ptc_status}\n" + "Child lock: {result.child_lock}\n" + "Buzzer: {result.buzzer}\n" + "Display: {result.display}\n" + "Display orientation: {result.display_orientation}\n", + ) + ) + def status(self) -> AirFreshStatus: + """Retrieve properties.""" + + properties = AVAILABLE_PROPERTIES[self.model] + + # A single request is limited to 16 properties. Therefore the + # properties are divided into multiple requests + _props = properties.copy() + values = [] + while _props: + values.extend(self.send("get_prop", _props[:15])) + _props[:] = _props[15:] + + properties_count = len(properties) + values_count = len(values) + if properties_count != values_count: + _LOGGER.debug( + "Count (%s) of requested properties does not match the " + "count (%s) of received values.", + properties_count, + values_count, + ) + + return AirFreshStatus(defaultdict(lambda: None, zip(properties, values))) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.send("set_power", ["on"]) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.send("set_power", ["off"]) + + @command( + click.argument("mode", type=EnumType(OperationMode, False)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode.""" + return self.send("set_mode", [mode.value]) + + @command( + click.argument("display", type=bool), + default_output=format_output( + lambda led: "Turning on display" if led else "Turning off display" + ), + ) + def set_display(self, display: bool): + """Turn led on/off.""" + if display: + return self.send("set_display", ["on"]) + else: + return self.send("set_display", ["off"]) + + @command( + click.argument("orientation", type=EnumType(DisplayOrientation, False)), + default_output=format_output("Setting orientation to '{orientation.value}'"), + ) + def set_display_orientation(self, orientation: DisplayOrientation): + """Set display orientation.""" + return self.send("set_screen_direction", [orientation.value]) + + @command( + click.argument("level", type=EnumType(PtcLevel, False)), + default_output=format_output("Setting ptc level to '{level.value}'"), + ) + def set_ptc_level(self, level: PtcLevel): + """Set PTC level.""" + return self.send("set_ptc_level", [level.value]) + + @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 sound on/off.""" + if buzzer: + return self.send("set_sound", ["on"]) + else: + return self.send("set_sound", ["off"]) + + @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.""" + if lock: + return self.send("set_child_lock", ["on"]) + else: + return self.send("set_child_lock", ["off"]) + + @command(default_output=format_output("Resetting upper filter")) + def reset_upper_filter(self): + """Resets filter lifetime of the upper filter.""" + return self.send("set_filter_reset", ["efficient"]) + + @command(default_output=format_output("Resetting dust filter")) + def reset_dust_filter(self): + """Resets filter lifetime of the dust filter.""" + return self.send("set_filter_reset", ["intermediate"]) + + @command( + click.argument("speed", type=int), + default_output=format_output("Setting favorite speed to {speed}"), + ) + def set_favorite_speed(self, speed: int): + """Storage register to enable extra features at the app.""" + if speed < 60 or speed > 300: + raise AirFreshException("Invalid favorite speed: %s" % speed) + + return self.send("set_favourite_speed", [speed]) + + @command() + def set_ptc_timer(self): + """ + value = time.index + '-' + + time.hexSum + '-' + + time.startTime + '-' + + time.ptcTimer.endTime + '-' + + time.level + '-' + + time.status; + return self.send("set_ptc_timer", [value]) + """ + raise NotImplementedError() + + @command() + def get_ptc_timer(self): + """Returns a list of PTC timers. Response unknown.""" + return self.send("get_ptc_timer") + + @command() + def get_timer(self): + """Response unknown.""" + return self.send("get_timer") diff --git a/miio/discovery.py b/miio/discovery.py index 092131698..d2281ff98 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -10,6 +10,7 @@ from . import ( AirConditioningCompanion, AirFresh, + AirFreshT2017, AirHumidifier, AirHumidifierMjjsq, AirPurifier, @@ -148,6 +149,7 @@ "dmaker-fan-p5": partial(Fan, model=MODEL_FAN_P5), "tinymu-toiletlid-v1": partial(Toiletlid, model=MODEL_TOILETLID_V1), "zhimi-airfresh-va2": AirFresh, + "dmaker-airfresh-t2017": AirFreshT2017, "zhimi-airmonitor-v1": partial(AirQualityMonitor, model=MODEL_AIRQUALITYMONITOR_V1), "cgllc-airmonitor-b1": partial(AirQualityMonitor, model=MODEL_AIRQUALITYMONITOR_B1), "cgllc-airmonitor-s1": partial(AirQualityMonitor, model=MODEL_AIRQUALITYMONITOR_S1), diff --git a/miio/tests/test_airfresh_t2017.py b/miio/tests/test_airfresh_t2017.py new file mode 100644 index 000000000..9ebef76c3 --- /dev/null +++ b/miio/tests/test_airfresh_t2017.py @@ -0,0 +1,226 @@ +from unittest import TestCase + +import pytest + +from miio import AirFreshT2017 +from miio.airfresh_t2017 import ( + MODEL_AIRFRESH_T2017, + AirFreshException, + AirFreshStatus, + DisplayOrientation, + OperationMode, + PtcLevel, +) + +from .dummies import DummyDevice + + +class DummyAirFreshT2017(DummyDevice, AirFreshT2017): + def __init__(self, *args, **kwargs): + self.model = MODEL_AIRFRESH_T2017 + self.state = { + "power": True, + "mode": "favourite", + "pm25": 1, + "co2": 550, + "temperature_outside": 24, + "favourite_speed": 241, + "control_speed": 241, + "filter_intermediate": 99, + "filter_inter_day": 89, + "filter_efficient": 99, + "filter_effi_day": 179, + "ptc_on": False, + "ptc_level": "low", + "ptc_status": False, + "child_lock": False, + "sound": True, + "display": False, + "screen_direction": "forward", + } + self.return_values = { + "get_prop": self._get_state, + "set_power": lambda x: self._set_state("power", [(x[0] == "on")]), + "set_mode": lambda x: self._set_state("mode", x), + "set_sound": lambda x: self._set_state("sound", [(x[0] == "on")]), + "set_child_lock": lambda x: self._set_state("child_lock", [(x[0] == "on")]), + "set_display": lambda x: self._set_state("display", [(x[0] == "on")]), + "set_screen_direction": lambda x: self._set_state("screen_direction", x), + "set_ptc_level": lambda x: self._set_state("ptc_level", x), + "set_favourite_speed": lambda x: self._set_state("favourite_speed", x), + "set_filter_reset": lambda x: self._set_filter_reset(x), + } + super().__init__(args, kwargs) + + def _set_filter_reset(self, value: str): + if value[0] == "efficient": + self._set_state("filter_efficient", [100]) + self._set_state("filter_effi_day", [180]) + + if value[0] == "intermediate": + self._set_state("filter_intermediate", [100]) + self._set_state("filter_inter_day", [90]) + + +@pytest.fixture(scope="class") +def airfresht2017(request): + request.cls.device = DummyAirFreshT2017() + # TODO add ability to test on a real device + + +@pytest.mark.usefixtures("airfresht2017") +class TestAirFreshT2017(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(AirFreshStatus(self.device.start_state)) + + assert self.is_on() is True + assert ( + self.state().temperature == self.device.start_state["temperature_outside"] + ) + assert self.state().co2 == self.device.start_state["co2"] + assert self.state().pm25 == self.device.start_state["pm25"] + assert self.state().mode == OperationMode(self.device.start_state["mode"]) + assert self.state().buzzer == self.device.start_state["sound"] + assert self.state().child_lock == self.device.start_state["child_lock"] + + def test_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Off) + assert mode() == OperationMode.Off + + self.device.set_mode(OperationMode.Auto) + assert mode() == OperationMode.Auto + + self.device.set_mode(OperationMode.Sleep) + assert mode() == OperationMode.Sleep + + self.device.set_mode(OperationMode.Favorite) + assert mode() == OperationMode.Favorite + + def test_set_display(self): + def display(): + return self.device.status().display + + self.device.set_display(True) + assert display() is True + + self.device.set_display(False) + assert display() is False + + 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_reset_dust_filter(self): + def dust_filter_life_remaining(): + return self.device.status().dust_filter_life_remaining + + def dust_filter_life_remaining_days(): + return self.device.status().dust_filter_life_remaining_days + + self.device._reset_state() + assert dust_filter_life_remaining() != 100 + assert dust_filter_life_remaining_days() != 90 + self.device.reset_dust_filter() + assert dust_filter_life_remaining() == 100 + assert dust_filter_life_remaining_days() == 90 + + def test_reset_upper_filter(self): + def upper_filter_life_remaining(): + return self.device.status().upper_filter_life_remaining + + def upper_filter_life_remaining_days(): + return self.device.status().upper_filter_life_remaining_days + + self.device._reset_state() + assert upper_filter_life_remaining() != 100 + assert upper_filter_life_remaining_days() != 180 + self.device.reset_upper_filter() + assert upper_filter_life_remaining() == 100 + assert upper_filter_life_remaining_days() == 180 + + def test_set_favorite_speed(self): + def favorite_speed(): + return self.device.status().favorite_speed + + self.device.set_favorite_speed(60) + assert favorite_speed() == 60 + self.device.set_favorite_speed(120) + assert favorite_speed() == 120 + self.device.set_favorite_speed(240) + assert favorite_speed() == 240 + self.device.set_favorite_speed(300) + assert favorite_speed() == 300 + + with pytest.raises(AirFreshException): + self.device.set_favorite_speed(-1) + + with pytest.raises(AirFreshException): + self.device.set_favorite_speed(59) + + with pytest.raises(AirFreshException): + self.device.set_favorite_speed(301) + + def test_set_ptc_level(self): + def ptc_level(): + return self.device.status().ptc_level + + self.device.set_ptc_level(PtcLevel.Off) + assert ptc_level() == PtcLevel.Off + self.device.set_ptc_level(PtcLevel.Low) + assert ptc_level() == PtcLevel.Low + self.device.set_ptc_level(PtcLevel.Medium) + assert ptc_level() == PtcLevel.Medium + self.device.set_ptc_level(PtcLevel.High) + assert ptc_level() == PtcLevel.High + + def test_set_display_orientation(self): + def display_orientation(): + return self.device.status().display_orientation + + self.device.set_display_orientation(DisplayOrientation.Portrait) + assert display_orientation() == DisplayOrientation.Portrait + self.device.set_display_orientation(DisplayOrientation.LandscapeLeft) + assert display_orientation() == DisplayOrientation.LandscapeLeft + self.device.set_display_orientation(DisplayOrientation.LandscapeRight) + assert display_orientation() == DisplayOrientation.LandscapeRight