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 1 commit
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 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
209 changes: 209 additions & 0 deletions miio/heater_miot.py
Original file line number Diff line number Diff line change
@@ -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
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 countdown_time(self) -> int:
"""Countdown time."""
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this indicate? Time until the device gets turned off? Maybe the property naming should follow the same format as in heater?

Copy link
Contributor Author

@bafonins bafonins Dec 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. The countdown_time field defines the number hours before the device goes off.
The problem here is that the spec defines this field as countdown_time while for miio heater it is delay_off_countdown. Shall I overwrite countdown_time to match the field name in heater (delay_off_countdown)?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, looking at the current code base, there are several instances of delay_off_countdown which returns the value in seconds.

So I'd say yes, it's better use that same naming scheme (and return the value in seconds even when the precision is much lower) just to be consistent.

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:
bafonins marked this conversation as resolved.
Show resolved Hide resolved
"""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, "
"countdown_time=%s "
% (
self.power,
self.target_temperature,
self.temperature,
self.brightness,
self.buzzer,
self.child_lock,
self.countdown_time,
)
)
return s


class HeaterMiot(MiotDevice):
"""Main class representing the heater which uses MIoT protocol."""
bafonins 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.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)
126 changes: 126 additions & 0 deletions miio/tests/test_heater_miot.py
Original file line number Diff line number Diff line change
@@ -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)