From 7d8b96ba93a06c95559bb8fb0be84dd16c876962 Mon Sep 17 00:00:00 2001 From: Bogdans Date: Wed, 6 Jan 2021 01:34:52 +0200 Subject: [PATCH] Add support for zhimi.heater.mc2 (#895) * Add support for zhimi.heater.mc2 * fixup! Fix docstring for HeaterMiot * fixup! Add zhimi.heater.mc2 to readme * fixup! Fix miot spec link * fixup! Add response example for heater * fixup! Rename brightness property of miot heater * fixup! Allow setting delay countdown in seconds * Add model info to docstring Co-authored-by: Teemu R --- README.rst | 1 + miio/__init__.py | 1 + miio/heater_miot.py | 228 +++++++++++++++++++++++++++++++++ miio/tests/test_heater_miot.py | 128 ++++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 miio/heater_miot.py create mode 100644 miio/tests/test_heater_miot.py diff --git a/README.rst b/README.rst index 3f42d825e..5f7c4c595 100644 --- a/README.rst +++ b/README.rst @@ -128,6 +128,7 @@ Supported devices - Smartmi Radiant Heater Smart Version (ZA1 version) - Xiaomi Mi Smart Space Heater - Xiaomiyoupin Curtain Controller (Wi-Fi) (lumi.curtain.hagl05) +- Xiaomi Xiaomi Mi Smart Space Heater S (zhimi.heater.mc2) *Feel free to create a pull request to add support for new devices as diff --git a/miio/__init__.py b/miio/__init__.py index cbfb0bb63..6d12d6631 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -38,6 +38,7 @@ from miio.gateway import Gateway from miio.gosund_plug import GosundPlug from miio.heater import Heater +from miio.heater_miot import HeaterMiot from miio.huizuo import Huizuo from miio.miot_device import MiotDevice from miio.philips_bulb import PhilipsBulb, PhilipsWhiteBulb diff --git a/miio/heater_miot.py b/miio/heater_miot.py new file mode 100644 index 000000000..8eeb2a1e8 --- /dev/null +++ b/miio/heater_miot.py @@ -0,0 +1,228 @@ +import enum +import logging +from typing import Any, Dict + +import click + +from .click_common import EnumType, command, format_output +from .exceptions import DeviceException +from .miot_device import MiotDevice + +_LOGGER = logging.getLogger(__name__) +_MAPPING = { + # Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:heater:0000A01A:zhimi-mc2:1 + # Heater (siid=2) + "power": {"siid": 2, "piid": 1}, + "target_temperature": {"siid": 2, "piid": 5}, + # Countdown (siid=3) + "countdown_time": {"siid": 3, "piid": 1}, + # Environment (siid=4) + "temperature": {"siid": 4, "piid": 7}, + # Physical Control Locked (siid=6) + "child_lock": {"siid": 5, "piid": 1}, + # Alarm (siid=6) + "buzzer": {"siid": 6, "piid": 1}, + # Indicator light (siid=7) + "led_brightness": {"siid": 7, "piid": 3}, +} + +HEATER_PROPERTIES = { + "temperature_range": (18, 28), + "delay_off_range": (0, 12 * 3600), +} + + +class LedBrightness(enum.Enum): + On = 0 + Off = 1 + + +class HeaterMiotException(DeviceException): + pass + + +class HeaterMiotStatus: + """Container for status reports from the Xiaomi Smart Space Heater S.""" + + def __init__(self, data: Dict[str, Any]) -> None: + """ + Response (MIoT format) of Xiaomi Smart Space Heater S (zhimi.heater.mc2): + + [ + { "did": "power", "siid": 2, "piid": 1, "code": 0, "value": False }, + { "did": "target_temperature", "siid": 2, "piid": 5, "code": 0, "value": 18 }, + { "did": "countdown_time", "siid": 3, "piid": 1, "code": 0, "value": 0 }, + { "did": "temperature", "siid": 4, "piid": 7, "code": 0, "value": 22.6 }, + { "did": "child_lock", "siid": 5, "piid": 1, "code": 0, "value": False }, + { "did": "buzzer", "siid": 6, "piid": 1, "code": 0, "value": False }, + { "did": "led_brightness", "siid": 7, "piid": 3, "code": 0, "value": 0 } + ] + """ + self.data = data + + @property + def power(self) -> str: + """Power state.""" + return "on" if self.is_on else "off" + + @property + def is_on(self) -> bool: + """True if device is currently on.""" + return self.data["power"] + + @property + def target_temperature(self) -> int: + """Target temperature.""" + return self.data["target_temperature"] + + @property + def delay_off_countdown(self) -> int: + """Countdown until turning off in seconds.""" + return self.data["countdown_time"] + + @property + def temperature(self) -> float: + """Current temperature.""" + return self.data["temperature"] + + @property + def child_lock(self) -> bool: + """True if child lock is on, False otherwise.""" + return self.data["child_lock"] is True + + @property + def buzzer(self) -> bool: + """True if buzzer is turned on, False otherwise.""" + return self.data["buzzer"] is True + + @property + def led_brightness(self) -> LedBrightness: + """LED indicator brightness.""" + return LedBrightness(self.data["led_brightness"]) + + def __repr__(self) -> str: + s = ( + " None: + super().__init__(_MAPPING, ip, token, start_id, debug, lazy_discover) + + @command( + default_output=format_output( + "", + "Power: {result.power}\n" + "Temperature: {result.temperature} °C\n" + "Target Temperature: {result.target_temperature} °C\n" + "LED indicator brightness: {result.led_brightness}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Power-off time: {result.delay_off_countdown} hours\n", + ) + ) + def status(self) -> HeaterMiotStatus: + """Retrieve properties.""" + + return HeaterMiotStatus( + { + 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("target_temperature", type=int), + default_output=format_output( + "Setting target temperature to '{target_temperature}'" + ), + ) + def set_target_temperature(self, target_temperature: int): + """Set target_temperature .""" + min_temp, max_temp = HEATER_PROPERTIES["temperature_range"] + if target_temperature < min_temp or target_temperature > max_temp: + raise HeaterMiotException( + "Invalid temperature: %s. Must be between %s and %s." + % (target_temperature, min_temp, max_temp) + ) + return self.set_property("target_temperature", target_temperature) + + @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("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.""" + return self.set_property("buzzer", buzzer) + + @command( + click.argument("brightness", type=EnumType(LedBrightness)), + default_output=format_output( + "Setting LED indicator brightness to {brightness}" + ), + ) + def set_led_brightness(self, brightness: LedBrightness): + """Set led brightness.""" + return self.set_property("led_brightness", brightness.value) + + @command( + click.argument("seconds", type=int), + default_output=format_output("Setting delayed turn off to {seconds} seconds"), + ) + def set_delay_off(self, seconds: int): + """Set delay off seconds.""" + min_delay, max_delay = HEATER_PROPERTIES["delay_off_range"] + if seconds < min_delay or seconds > max_delay: + raise HeaterMiotException( + "Invalid scheduled turn off: %s. Must be between %s and %s" + % (seconds, min_delay, max_delay) + ) + return self.set_property("countdown_time", seconds // 3600) diff --git a/miio/tests/test_heater_miot.py b/miio/tests/test_heater_miot.py new file mode 100644 index 000000000..57fd565cd --- /dev/null +++ b/miio/tests/test_heater_miot.py @@ -0,0 +1,128 @@ +from unittest import TestCase + +import pytest + +from miio import HeaterMiot +from miio.heater_miot import HeaterMiotException, LedBrightness + +from .dummies import DummyMiotDevice + +_INITIAL_STATE = { + "power": True, + "temperature": 21.6, + "target_temperature": 23, + "buzzer": False, + "led_brightness": 1, + "child_lock": False, + "countdown_time": 0, +} + + +class DummyHeaterMiot(DummyMiotDevice, HeaterMiot): + def __init__(self, *args, **kwargs): + self.state = _INITIAL_STATE + self.return_values = { + "get_prop": self._get_state, + "set_power": lambda x: self._set_state("power", x), + "set_led_brightness": lambda x: self._set_state("led_brightness", x), + "set_buzzer": lambda x: self._set_state("buzzer", x), + "set_child_lock": lambda x: self._set_state("child_lock", x), + "set_delay_off": lambda x: self._set_state("countdown_time", x), + "set_target_temperature": lambda x: self._set_state( + "target_temperature", x + ), + } + super().__init__(*args, **kwargs) + + +@pytest.fixture(scope="class") +def heater(request): + request.cls.device = DummyHeaterMiot() + + +@pytest.mark.usefixtures("heater") +class TestHeater(TestCase): + def is_on(self): + return self.device.status().is_on + + def test_on(self): + self.device.off() + assert self.is_on() is False + + self.device.on() + assert self.is_on() is True + + def test_off(self): + self.device.on() + assert self.is_on() is True + + self.device.off() + assert self.is_on() is False + + def test_set_led_brightness(self): + def led_brightness(): + return self.device.status().led_brightness + + self.device.set_led_brightness(LedBrightness.On) + assert led_brightness() == LedBrightness.On + + self.device.set_led_brightness(LedBrightness.Off) + assert led_brightness() == LedBrightness.Off + + def test_set_buzzer(self): + def buzzer(): + return self.device.status().buzzer + + self.device.set_buzzer(True) + assert buzzer() is True + + self.device.set_buzzer(False) + assert buzzer() is False + + def test_set_child_lock(self): + def child_lock(): + return self.device.status().child_lock + + self.device.set_child_lock(True) + assert child_lock() is True + + self.device.set_child_lock(False) + assert child_lock() is False + + def test_set_delay_off(self): + def delay_off_countdown(): + return self.device.status().delay_off_countdown + + self.device.set_delay_off(0) + assert delay_off_countdown() == 0 + self.device.set_delay_off(9 * 3600) + assert delay_off_countdown() == 9 + self.device.set_delay_off(12 * 3600) + assert delay_off_countdown() == 12 + self.device.set_delay_off(9 * 3600 + 1) + assert delay_off_countdown() == 9 + + with pytest.raises(HeaterMiotException): + self.device.set_delay_off(-1) + + with pytest.raises(HeaterMiotException): + self.device.set_delay_off(13 * 3600) + + def test_set_target_temperature(self): + def target_temperature(): + return self.device.status().target_temperature + + self.device.set_target_temperature(18) + assert target_temperature() == 18 + + self.device.set_target_temperature(23) + assert target_temperature() == 23 + + self.device.set_target_temperature(28) + assert target_temperature() == 28 + + with pytest.raises(HeaterMiotException): + self.device.set_target_temperature(17) + + with pytest.raises(HeaterMiotException): + self.device.set_target_temperature(29)