From 8c0a285c0bd0ef0d8b9ba5e8dd393d3909f5fb3f Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 25 Dec 2020 15:17:11 +0200 Subject: [PATCH 1/8] Add support for zhimi.heater.mc2 --- miio/__init__.py | 1 + miio/heater_miot.py | 209 +++++++++++++++++++++++++++++++++ miio/tests/test_heater_miot.py | 126 ++++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 miio/heater_miot.py create mode 100644 miio/tests/test_heater_miot.py diff --git a/miio/__init__.py b/miio/__init__.py index 4e0783bea..addcbdd71 100644 --- a/miio/__init__.py +++ b/miio/__init__.py @@ -37,6 +37,7 @@ from miio.fan_miot import FanMiot, FanP9, FanP10, FanP11 from miio.gateway import Gateway 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..95823d458 --- /dev/null +++ b/miio/heater_miot.py @@ -0,0 +1,209 @@ +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}, +} + + +class LedBrightness(enum.Enum): + On = 0 + Off = 1 + + +class HeaterMiotException(DeviceException): + pass + + +class HeaterMiotStatus: + """Container for status reports from the heater which uses the MIoT protocol.""" + + def __init__(self, data: Dict[str, Any]) -> None: + 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 countdown_time(self) -> int: + """Countdown time.""" + 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 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.brightness}\n" + "Buzzer: {result.buzzer}\n" + "Child lock: {result.child_lock}\n" + "Countdown time: {result.countdown_time} 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 .""" + if target_temperature < 18 or target_temperature > 28: + raise HeaterMiotException( + "Invalid temperature: %s. Must be between 18 and 28." + % target_temperature + ) + return self.set_property("target_temperature", target_temperature) + + @command( + click.argument("hours", type=int), + default_output=format_output( + "Scheduling the heater to turn off in '{hours}' hours" + ), + ) + def set_countdown_time(self, hours: int): + """Set scheduled turn off.""" + if hours < 0 or hours > 12: + raise HeaterMiotException( + "Invalid scheduled turn off: %s. Must be between 0 and 12" % hours + ) + return self.set_property("countdown_time", hours) + + @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) diff --git a/miio/tests/test_heater_miot.py b/miio/tests/test_heater_miot.py new file mode 100644 index 000000000..ffdf5803c --- /dev/null +++ b/miio/tests/test_heater_miot.py @@ -0,0 +1,126 @@ +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_countdown_time": 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().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_countdown_time(self): + def countdown_time(): + return self.device.status().countdown_time + + self.device.set_countdown_time(0) + assert countdown_time() == 0 + self.device.set_countdown_time(9) + assert countdown_time() == 9 + self.device.set_countdown_time(12) + assert countdown_time() == 12 + + with pytest.raises(HeaterMiotException): + self.device.set_countdown_time(-1) + + with pytest.raises(HeaterMiotException): + self.device.set_countdown_time(13) + + 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) From 3da8aac6cd42fdea566a5e3ee6e15dd0d651fc57 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 29 Dec 2020 17:42:05 +0200 Subject: [PATCH 2/8] fixup! Fix docstring for HeaterMiot --- miio/heater_miot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/miio/heater_miot.py b/miio/heater_miot.py index 95823d458..978852569 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -37,7 +37,7 @@ class HeaterMiotException(DeviceException): class HeaterMiotStatus: - """Container for status reports from the heater which uses the MIoT protocol.""" + """Container for status reports from the Xiaomi Smart Space Heater S.""" def __init__(self, data: Dict[str, Any]) -> None: self.data = data @@ -105,7 +105,7 @@ def __repr__(self) -> str: class HeaterMiot(MiotDevice): - """Main class representing the heater which uses MIoT protocol.""" + """Main class representing the Xiaomi Smart Space Heater S.""" def __init__( self, From 8f136aeb3f460fd76f40cfd32282091def24ce03 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 29 Dec 2020 17:46:06 +0200 Subject: [PATCH 3/8] fixup! Add zhimi.heater.mc2 to readme --- README.rst | 1 + 1 file changed, 1 insertion(+) 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 From eaf11a5636c5ed2066f0308d3d80b5633f25d77b Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 29 Dec 2020 18:11:51 +0200 Subject: [PATCH 4/8] fixup! Fix miot spec link --- miio/heater_miot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/heater_miot.py b/miio/heater_miot.py index 978852569..be6e11008 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -10,7 +10,7 @@ _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 + # 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}, From 65eae1214c9b2bc28013f2effa831d0a6d8b75f0 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Tue, 29 Dec 2020 18:13:05 +0200 Subject: [PATCH 5/8] fixup! Add response example for heater --- miio/heater_miot.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/miio/heater_miot.py b/miio/heater_miot.py index be6e11008..fa5ee7684 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -40,6 +40,19 @@ 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 From c0b671b7dc318b614d469b1714b271f850d88506 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 2 Jan 2021 13:45:41 +0200 Subject: [PATCH 6/8] fixup! Rename brightness property of miot heater --- miio/heater_miot.py | 4 ++-- miio/tests/test_heater_miot.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/miio/heater_miot.py b/miio/heater_miot.py index fa5ee7684..003ad3e79 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -91,7 +91,7 @@ def buzzer(self) -> bool: return self.data["buzzer"] is True @property - def brightness(self) -> LedBrightness: + def led_brightness(self) -> LedBrightness: """LED indicator brightness.""" return LedBrightness(self.data["led_brightness"]) @@ -108,7 +108,7 @@ def __repr__(self) -> str: self.power, self.target_temperature, self.temperature, - self.brightness, + self.led_brightness, self.buzzer, self.child_lock, self.countdown_time, diff --git a/miio/tests/test_heater_miot.py b/miio/tests/test_heater_miot.py index ffdf5803c..055d4cd49 100644 --- a/miio/tests/test_heater_miot.py +++ b/miio/tests/test_heater_miot.py @@ -61,7 +61,7 @@ def test_off(self): def test_set_led_brightness(self): def led_brightness(): - return self.device.status().brightness + return self.device.status().led_brightness self.device.set_led_brightness(LedBrightness.On) assert led_brightness() == LedBrightness.On From 8f57096cefd4b5b36751a6c54d285c55155b7bb4 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sat, 2 Jan 2021 14:22:42 +0200 Subject: [PATCH 7/8] fixup! Allow setting delay countdown in seconds --- miio/heater_miot.py | 52 +++++++++++++++++++--------------- miio/tests/test_heater_miot.py | 26 +++++++++-------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/miio/heater_miot.py b/miio/heater_miot.py index 003ad3e79..e2302960c 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -26,6 +26,11 @@ "led_brightness": {"siid": 7, "piid": 3}, } +HEATER_PROPERTIES = { + "temperature_range": (18, 28), + "delay_off_range": (0, 12 * 3600), +} + class LedBrightness(enum.Enum): On = 0 @@ -71,8 +76,8 @@ def target_temperature(self) -> int: return self.data["target_temperature"] @property - def countdown_time(self) -> int: - """Countdown time.""" + def delay_off_countdown(self) -> int: + """Countdown until turning off in seconds.""" return self.data["countdown_time"] @property @@ -103,7 +108,7 @@ def __repr__(self) -> str: "led_brightness=%s, " "buzzer=%s, " "child_lock=%s, " - "countdown_time=%s " + "delay_off_countdown=%s " % ( self.power, self.target_temperature, @@ -111,7 +116,7 @@ def __repr__(self) -> str: self.led_brightness, self.buzzer, self.child_lock, - self.countdown_time, + self.delay_off_countdown, ) ) return s @@ -136,10 +141,10 @@ def __init__( "Power: {result.power}\n" "Temperature: {result.temperature} °C\n" "Target Temperature: {result.target_temperature} °C\n" - "LED indicator brightness: {result.brightness}\n" + "LED indicator brightness: {result.led_brightness}\n" "Buzzer: {result.buzzer}\n" "Child lock: {result.child_lock}\n" - "Countdown time: {result.countdown_time} hours\n", + "Power-off time: {result.delay_off_countdown} hours\n", ) ) def status(self) -> HeaterMiotStatus: @@ -170,27 +175,14 @@ def off(self): ) def set_target_temperature(self, target_temperature: int): """Set target_temperature .""" - if target_temperature < 18 or target_temperature > 28: + 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 18 and 28." - % target_temperature + "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("hours", type=int), - default_output=format_output( - "Scheduling the heater to turn off in '{hours}' hours" - ), - ) - def set_countdown_time(self, hours: int): - """Set scheduled turn off.""" - if hours < 0 or hours > 12: - raise HeaterMiotException( - "Invalid scheduled turn off: %s. Must be between 0 and 12" % hours - ) - return self.set_property("countdown_time", hours) - @command( click.argument("lock", type=bool), default_output=format_output( @@ -220,3 +212,17 @@ def set_buzzer(self, buzzer: bool): 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 index 055d4cd49..57fd565cd 100644 --- a/miio/tests/test_heater_miot.py +++ b/miio/tests/test_heater_miot.py @@ -27,7 +27,7 @@ def __init__(self, *args, **kwargs): "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_countdown_time": lambda x: self._set_state("countdown_time", x), + "set_delay_off": lambda x: self._set_state("countdown_time", x), "set_target_temperature": lambda x: self._set_state( "target_temperature", x ), @@ -89,22 +89,24 @@ def child_lock(): self.device.set_child_lock(False) assert child_lock() is False - def test_set_countdown_time(self): - def countdown_time(): - return self.device.status().countdown_time + def test_set_delay_off(self): + def delay_off_countdown(): + return self.device.status().delay_off_countdown - self.device.set_countdown_time(0) - assert countdown_time() == 0 - self.device.set_countdown_time(9) - assert countdown_time() == 9 - self.device.set_countdown_time(12) - assert countdown_time() == 12 + 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_countdown_time(-1) + self.device.set_delay_off(-1) with pytest.raises(HeaterMiotException): - self.device.set_countdown_time(13) + self.device.set_delay_off(13 * 3600) def test_set_target_temperature(self): def target_temperature(): From c31316c2b4db5591cfd917d841f72f799de58449 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 6 Jan 2021 00:20:16 +0100 Subject: [PATCH 8/8] Add model info to docstring --- miio/heater_miot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/miio/heater_miot.py b/miio/heater_miot.py index e2302960c..8eeb2a1e8 100644 --- a/miio/heater_miot.py +++ b/miio/heater_miot.py @@ -123,7 +123,7 @@ def __repr__(self) -> str: class HeaterMiot(MiotDevice): - """Main class representing the Xiaomi Smart Space Heater S.""" + """Main class representing the Xiaomi Smart Space Heater S (zhimi.heater.mc2).""" def __init__( self,