Skip to content

Commit

Permalink
Add support for zhimi.heater.mc2 (rytilahti#895)
Browse files Browse the repository at this point in the history
* 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 <tpr@iki.fi>
  • Loading branch information
2 people authored and xvlady committed May 9, 2021
1 parent 73ba612 commit 7d8b96b
Show file tree
Hide file tree
Showing 4 changed files with 358 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
228 changes: 228 additions & 0 deletions miio/heater_miot.py
Original file line number Diff line number Diff line change
@@ -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 = (
"<HeaterMiotStatus power=%s, "
"target_temperature=%s, "
"temperature=%s, "
"led_brightness=%s, "
"buzzer=%s, "
"child_lock=%s, "
"delay_off_countdown=%s "
% (
self.power,
self.target_temperature,
self.temperature,
self.led_brightness,
self.buzzer,
self.child_lock,
self.delay_off_countdown,
)
)
return s


class HeaterMiot(MiotDevice):
"""Main class representing the Xiaomi Smart Space Heater S (zhimi.heater.mc2)."""

def __init__(
self,
ip: str = None,
token: str = None,
start_id: int = 0,
debug: int = 0,
lazy_discover: bool = True,
) -> 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)
128 changes: 128 additions & 0 deletions miio/tests/test_heater_miot.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 7d8b96b

Please sign in to comment.