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