diff --git a/README.rst b/README.rst index 0897b3b1f..6428c53c5 100644 --- a/README.rst +++ b/README.rst @@ -105,7 +105,7 @@ Supported devices - Xiaomi Philips Zhirui Smart LED Bulb E14 Candle Lamp - Xiaomi Philips Zhirui Bedroom Smart Lamp - Xiaomi Universal IR Remote Controller (Chuangmi IR) -- Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, P5 +- Xiaomi Mi Smart Pedestal Fan V2, V3, SA1, ZA1, ZA3, ZA4, P5, P9, P10 - Xiaomi Mi Air Humidifier V1, CA1, CA4, CB1, MJJSQ, JSQ001 - Xiaomi Mi Water Purifier (Basic support: Turn on & off) - Xiaomi PM2.5 Air Quality Monitor V1, B1, S1 diff --git a/docs/api/miio.airconditioningcompanionMCN.rst b/docs/api/miio.airconditioningcompanionMCN.rst new file mode 100644 index 000000000..e99f32690 --- /dev/null +++ b/docs/api/miio.airconditioningcompanionMCN.rst @@ -0,0 +1,7 @@ +miio.airconditioningcompanionMCN module +======================================= + +.. automodule:: miio.airconditioningcompanionMCN + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/miio.fan_common.rst b/docs/api/miio.fan_common.rst new file mode 100644 index 000000000..55579912a --- /dev/null +++ b/docs/api/miio.fan_common.rst @@ -0,0 +1,7 @@ +miio.fan\_common module +======================= + +.. automodule:: miio.fan_common + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/miio.fan_miot.rst b/docs/api/miio.fan_miot.rst new file mode 100644 index 000000000..f2042f96d --- /dev/null +++ b/docs/api/miio.fan_miot.rst @@ -0,0 +1,7 @@ +miio.fan\_miot module +===================== + +.. automodule:: miio.fan_miot + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/api/miio.rst b/docs/api/miio.rst index 6c69822c4..08897c3c6 100644 --- a/docs/api/miio.rst +++ b/docs/api/miio.rst @@ -35,6 +35,8 @@ Submodules miio.exceptions miio.extract_tokens miio.fan + miio.fan_common + miio.fan_miot miio.gateway miio.heater miio.miioprotocol diff --git a/miio/__init__.py b/miio/__init__.py index 492800245..d304229d5 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -30,6 +30,7 @@ from miio.device import Device from miio.exceptions import DeviceError, DeviceException from miio.fan import Fan, FanP5, FanSA1, FanV2, FanZA1, FanZA4 +from miio.fan_miot import FanMiot, FanP9, FanP10 from miio.gateway import Gateway from miio.heater import Heater from miio.philips_bulb import PhilipsBulb, PhilipsWhiteBulb diff --git a/miio/discovery.py b/miio/discovery.py index 7fea3cf55..7e2b55275 100644 --- a/miio/discovery.py +++ b/miio/discovery.py @@ -26,6 +26,7 @@ Cooker, Device, Fan, + FanMiot, Heater, PhilipsBulb, PhilipsEyecare, @@ -78,6 +79,7 @@ MODEL_FAN_ZA3, MODEL_FAN_ZA4, ) +from .fan_miot import MODEL_FAN_P9, MODEL_FAN_P10 from .heater import MODEL_HEATER_MA1, MODEL_HEATER_ZA1 from .powerstrip import MODEL_POWER_STRIP_V1, MODEL_POWER_STRIP_V2 from .toiletlid import MODEL_TOILETLID_V1 @@ -161,6 +163,8 @@ "zhimi-fan-za3": partial(Fan, model=MODEL_FAN_ZA3), "zhimi-fan-za4": partial(Fan, model=MODEL_FAN_ZA4), "dmaker-fan-p5": partial(Fan, model=MODEL_FAN_P5), + "dmaker-fan-p9": partial(FanMiot, model=MODEL_FAN_P9), + "dmaker-fan-p10": partial(FanMiot, model=MODEL_FAN_P10), "tinymu-toiletlid-v1": partial(Toiletlid, model=MODEL_TOILETLID_V1), "zhimi-airfresh-va2": partial(AirFresh, model=MODEL_AIRFRESH_VA2), "zhimi-airfresh-va4": partial(AirFresh, model=MODEL_AIRFRESH_VA4), diff --git a/miio/fan.py b/miio/fan.py index caef93606..bf15f7854 100644 --- a/miio/fan.py +++ b/miio/fan.py @@ -1,4 +1,3 @@ -import enum import logging from typing import Any, Dict, Optional @@ -6,7 +5,7 @@ from .click_common import EnumType, command, format_output from .device import Device -from .exceptions import DeviceException +from .fan_common import FanException, LedBrightness, MoveDirection, OperationMode _LOGGER = logging.getLogger(__name__) @@ -64,26 +63,6 @@ } -class FanException(DeviceException): - pass - - -class OperationMode(enum.Enum): - Normal = "normal" - Nature = "nature" - - -class LedBrightness(enum.Enum): - Bright = 0 - Dim = 1 - Off = 2 - - -class MoveDirection(enum.Enum): - Left = "left" - Right = "right" - - class FanStatus: """Container for status reports from the Xiaomi Mi Smart Pedestal Fan.""" diff --git a/miio/fan_common.py b/miio/fan_common.py new file mode 100644 index 000000000..08d13ec08 --- /dev/null +++ b/miio/fan_common.py @@ -0,0 +1,23 @@ +import enum + +from .exceptions import DeviceException + + +class FanException(DeviceException): + pass + + +class OperationMode(enum.Enum): + Normal = "normal" + Nature = "nature" + + +class LedBrightness(enum.Enum): + Bright = 0 + Dim = 1 + Off = 2 + + +class MoveDirection(enum.Enum): + Left = "left" + Right = "right" diff --git a/miio/fan_miot.py b/miio/fan_miot.py new file mode 100644 index 000000000..3804e804e --- /dev/null +++ b/miio/fan_miot.py @@ -0,0 +1,326 @@ +import enum +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .fan_common import FanException, MoveDirection, OperationMode +from .miot_device import MiotDevice + +MODEL_FAN_P9 = "dmaker.fan.p9" +MODEL_FAN_P10 = "dmaker.fan.p10" + +MIOT_MAPPING = { + MODEL_FAN_P9: { + # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p9:1 + "power": {"siid": 2, "piid": 1}, + "fan_level": {"siid": 2, "piid": 2}, + "child_lock": {"siid": 3, "piid": 1}, + "fan_speed": {"siid": 2, "piid": 11}, + "swing_mode": {"siid": 2, "piid": 5}, + "swing_mode_angle": {"siid": 2, "piid": 6}, + "power_off_time": {"siid": 2, "piid": 8}, + "buzzer": {"siid": 2, "piid": 7}, + "light": {"siid": 2, "piid": 9}, + "mode": {"siid": 2, "piid": 4}, + "set_move": {"siid": 2, "piid": 10}, + }, + MODEL_FAN_P10: { + # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:fan:0000A005:dmaker-p10:1 + "power": {"siid": 2, "piid": 1}, + "fan_level": {"siid": 2, "piid": 2}, + "child_lock": {"siid": 3, "piid": 1}, + "fan_speed": {"siid": 2, "piid": 10}, + "swing_mode": {"siid": 2, "piid": 4}, + "swing_mode_angle": {"siid": 2, "piid": 5}, + "power_off_time": {"siid": 2, "piid": 6}, + "buzzer": {"siid": 2, "piid": 8}, + "light": {"siid": 2, "piid": 7}, + "mode": {"siid": 2, "piid": 3}, + "set_move": {"siid": 2, "piid": 9}, + }, +} + + +class OperationModeMiot(enum.Enum): + Normal = 0 + Nature = 1 + + +class FanStatusMiot: + """Container for status reports from the Xiaomi Mi Smart Pedestal Fan DMaker P9/P10.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response of a FanMiot (dmaker.fan.p10): + + { + 'id': 1, + 'result': [ + {'did': 'power', 'siid': 2, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'fan_level', 'siid': 2, 'piid': 2, 'code': 0, 'value': 2}, + {'did': 'child_lock', 'siid': 3, 'piid': 1, 'code': 0, 'value': False}, + {'did': 'fan_speed', 'siid': 2, 'piid': 10, 'code': 0, 'value': 54}, + {'did': 'swing_mode', 'siid': 2, 'piid': 4, 'code': 0, 'value': False}, + {'did': 'swing_mode_angle', 'siid': 2, 'piid': 5, 'code': 0, 'value': 30}, + {'did': 'power_off_time', 'siid': 2, 'piid': 6, 'code': 0, 'value': 0}, + {'did': 'buzzer', 'siid': 2, 'piid': 8, 'code': 0, 'value': False}, + {'did': 'light', 'siid': 2, 'piid': 7, 'code': 0, 'value': True}, + {'did': 'mode', 'siid': 2, 'piid': 3, 'code': 0, 'value': 0}, + {'did': 'set_move', 'siid': 2, 'piid': 9, 'code': -4003} + ], + 'exe_time': 280 + } + """ + self.data = data + + @property + def power(self) -> str: + """Power state.""" + return "on" if self.data["power"] else "off" + + @property + def is_on(self) -> bool: + """True if device is currently on.""" + return self.data["power"] + + @property + def mode(self) -> OperationMode: + """Operation mode.""" + return OperationMode[OperationModeMiot(self.data["mode"]).name] + + @property + def speed(self) -> int: + """Speed of the motor.""" + return self.data["fan_speed"] + + @property + def oscillate(self) -> bool: + """True if oscillation is enabled.""" + return self.data["swing_mode"] + + @property + def angle(self) -> int: + """Oscillation angle.""" + return self.data["swing_mode_angle"] + + @property + def delay_off_countdown(self) -> int: + """Countdown until turning off in seconds.""" + return self.data["power_off_time"] + + @property + def led(self) -> bool: + """True if LED is turned on, if available.""" + return self.data["light"] + + @property + def buzzer(self) -> bool: + """True if buzzer is turned on.""" + return self.data["buzzer"] + + @property + def child_lock(self) -> bool: + """True if child lock is on.""" + return self.data["child_lock"] + + def __repr__(self) -> str: + s = ( + "" + % ( + self.power, + self.mode, + self.speed, + self.oscillate, + self.angle, + self.led, + self.buzzer, + self.child_lock, + self.delay_off_countdown, + ) + ) + return s + + +class FanMiot(MiotDevice): + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + model: str = MODEL_FAN_P10, + ) -> None: + if model in MIOT_MAPPING: + self.model = model + else: + raise FanException("Invalid FanMiot model: %s" % model) + super().__init__(MIOT_MAPPING[model], ip, token, start_id, debug, lazy_discover) + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Operation mode: {result.mode}\n" + "Speed: {result.speed}\n" + "Oscillate: {result.oscillate}\n" + "Angle: {result.angle}\n" + "LED: {result.led}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Power-off time: {result.delay_off_countdown}\n", + ) + ) + def status(self) -> FanStatusMiot: + """Retrieve properties.""" + return FanStatusMiot( + { + prop["did"]: prop["value"] if prop["code"] == 0 else None + for prop in self.get_properties_for_mapping() + } + ) + + @command(default_output=format_output("Powering on")) + def on(self): + """Power on.""" + return self.set_property("power", True) + + @command(default_output=format_output("Powering off")) + def off(self): + """Power off.""" + return self.set_property("power", False) + + @command( + click.argument("mode", type=EnumType(OperationMode)), + default_output=format_output("Setting mode to '{mode.value}'"), + ) + def set_mode(self, mode: OperationMode): + """Set mode.""" + return self.set_property("mode", OperationModeMiot[mode.name].value) + + @command( + click.argument("speed", type=int), + default_output=format_output("Setting speed to {speed}"), + ) + def set_speed(self, speed: int): + """Set speed.""" + if speed < 0 or speed > 100: + raise FanException("Invalid speed: %s" % speed) + + return self.set_property("fan_speed", speed) + + @command( + click.argument("angle", type=int), + default_output=format_output("Setting angle to {angle}"), + ) + def set_angle(self, angle: int): + """Set the oscillation angle.""" + if angle not in [30, 60, 90, 120, 140]: + raise FanException( + "Unsupported angle. Supported values: 30, 60, 90, 120, 140" + ) + + return self.set_property("swing_mode_angle", angle) + + @command( + click.argument("oscillate", type=bool), + default_output=format_output( + lambda oscillate: "Turning on oscillate" + if oscillate + else "Turning off oscillate" + ), + ) + def set_oscillate(self, oscillate: bool): + """Set oscillate on/off.""" + if oscillate: + return self.set_property("swing_mode", True) + else: + return self.set_property("swing_mode", False) + + @command( + click.argument("led", type=bool), + default_output=format_output( + lambda led: "Turning on LED" if led else "Turning off LED" + ), + ) + def set_led(self, led: bool): + """Turn led on/off.""" + if led: + return self.set_property("light", True) + else: + return self.set_property("light", False) + + @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.""" + if buzzer: + return self.set_property("buzzer", True) + else: + return self.set_property("buzzer", False) + + @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.""" + return self.set_property("child_lock", lock) + + @command( + click.argument("minutes", type=int), + default_output=format_output("Setting delayed turn off to {minutes} minutes"), + ) + def delay_off(self, minutes: int): + """Set delay off minutes.""" + + if minutes < 0: + raise FanException("Invalid value for a delayed turn off: %s" % minutes) + + return self.set_property("power_off_time", minutes) + + @command( + click.argument("direction", type=EnumType(MoveDirection)), + default_output=format_output("Rotating the fan to the {direction}"), + ) + def set_rotate(self, direction: MoveDirection): + return self.set_property("set_move", [direction.value]) + + +class FanP9(FanMiot): + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + ) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover, model=MODEL_FAN_P9) + + +class FanP10(FanMiot): + def __init__( + self, + ip: str = None, + token: str = None, + start_id: int = 0, + debug: int = 0, + lazy_discover: bool = True, + ) -> None: + super().__init__(ip, token, start_id, debug, lazy_discover, model=MODEL_FAN_P10) diff --git a/miio/tests/test_fan_miot.py b/miio/tests/test_fan_miot.py new file mode 100644 index 000000000..2fcac7eb0 --- /dev/null +++ b/miio/tests/test_fan_miot.py @@ -0,0 +1,175 @@ +from unittest import TestCase + +import pytest + +from miio import FanMiot +from miio.fan_miot import MODEL_FAN_P9, FanException, OperationMode + +from .dummies import DummyMiotDevice + + +class DummyFanMiot(DummyMiotDevice, FanMiot): + def __init__(self, *args, **kwargs): + self.model = MODEL_FAN_P9 + self.state = { + "power": True, + "mode": 0, + "fan_speed": 35, + "swing_mode": False, + "swing_mode_angle": 140, + "power_off_time": 0, + "light": True, + "buzzer": False, + "child_lock": False, + } + + self.return_values = { + "get_prop": self._get_state, + "power": lambda x: self._set_state("power", x), + "mode": lambda x: self._set_state("mode", x), + "fan_speed": lambda x: self._set_state("fan_speed", x), + "swing_mode": lambda x: self._set_state("swing_mode", x), + "swing_mode_angle": lambda x: self._set_state("swing_mode_angle", x), + "power_off_time": lambda x: self._set_state("power_off_time", x), + "light": lambda x: self._set_state("light", x), + "buzzer": lambda x: self._set_state("buzzer", x), + "child_lock": lambda x: self._set_state("child_lock", x), + "set_move": lambda x: True, + } + super().__init__(args, kwargs) + + +@pytest.fixture(scope="class") +def fanmiot(request): + request.cls.device = DummyFanMiot() + + +@pytest.mark.usefixtures("fanmiot") +class TestFanMiot(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_set_mode(self): + def mode(): + return self.device.status().mode + + self.device.set_mode(OperationMode.Normal) + assert mode() == OperationMode.Normal + + self.device.set_mode(OperationMode.Nature) + assert mode() == OperationMode.Nature + + def test_set_speed(self): + def speed(): + return self.device.status().speed + + self.device.set_speed(0) + assert speed() == 0 + self.device.set_speed(1) + assert speed() == 1 + self.device.set_speed(100) + assert speed() == 100 + + with pytest.raises(FanException): + self.device.set_speed(-1) + + with pytest.raises(FanException): + self.device.set_speed(101) + + def test_set_angle(self): + def angle(): + return self.device.status().angle + + 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 + self.device.set_angle(140) + assert angle() == 140 + + with pytest.raises(FanException): + self.device.set_angle(-1) + + with pytest.raises(FanException): + self.device.set_angle(1) + + with pytest.raises(FanException): + self.device.set_angle(31) + + with pytest.raises(FanException): + self.device.set_angle(141) + + 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(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_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 + self.device.delay_off(0) + assert delay_off_countdown() == 0 + + with pytest.raises(FanException): + self.device.delay_off(-1)