Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for zhimi.heater.mc2 #895

Merged
merged 8 commits into from
Jan 5, 2021
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
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
bafonins marked this conversation as resolved.
Show resolved Hide resolved

@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."""
rytilahti marked this conversation as resolved.
Show resolved Hide resolved

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)