import enum
import logging
from typing import Any, Dict, Optional
import click
from miio import DeviceStatus, MiotDevice
from miio.click_common import EnumType, command, format_output
_LOGGER = logging.getLogger(__name__)
_MAPPINGS = {
"zhimi.heater.mc2": {
# 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=5)
"child_lock": {"siid": 5, "piid": 1},
# Alarm (siid=6)
"buzzer": {"siid": 6, "piid": 1},
# Indicator light (siid=7)
"led_brightness": {"siid": 7, "piid": 3},
},
"zhimi.heater.mc2a": {
# Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:heater:0000A01A:zhimi-mc2a: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=5)
"child_lock": {"siid": 5, "piid": 1},
# Alarm (siid=6)
"buzzer": {"siid": 6, "piid": 1},
# Indicator light (siid=7)
"led_brightness": {"siid": 7, "piid": 3},
},
"zhimi.heater.za2": {
# Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:heater:0000A01A:zhimi-za2:1
# Heater (siid=2)
"power": {"siid": 2, "piid": 2},
"target_temperature": {"siid": 2, "piid": 6},
# Countdown (siid=4)
"countdown_time": {"siid": 4, "piid": 1},
# Environment (siid=5)
"temperature": {"siid": 5, "piid": 8},
"relative_humidity": {"siid": 5, "piid": 7},
# Physical Control Locked (siid=7)
"child_lock": {"siid": 7, "piid": 1},
# Alarm (siid=3)
"buzzer": {"siid": 3, "piid": 1},
# Indicator light (siid=7)
"led_brightness": {"siid": 6, "piid": 1},
},
"leshow.heater.bs1s": {
# Source https://miot-spec.org/miot-spec-v2/instance?type=urn:miot-spec-v2:device:heater:0000A01A:leshow-bs1:1
# Heater (siid=2)
"power": {"siid": 2, "piid": 1},
"target_temperature": {"siid": 2, "piid": 3},
# Countdown (siid=3)
"countdown_time": {"siid": 3, "piid": 1},
# Environment (siid=4)
"temperature": {"siid": 4, "piid": 7},
# Physical Control Locked (siid=5)
"child_lock": {"siid": 5, "piid": 1},
# Alarm (siid=6)
"buzzer": {"siid": 6, "piid": 1},
# Indicator light (siid=7)
"led_brightness": {"siid": 7, "piid": 1},
},
}
HEATER_PROPERTIES = {
"zhimi.heater.mc2": {
"temperature_range": (18, 28),
"delay_off_range": (0, 12 * 3600),
},
"zhimi.heater.mc2a": {
"temperature_range": (18, 28),
"delay_off_range": (0, 12 * 3600),
},
"zhimi.heater.za2": {
"temperature_range": (16, 28),
"delay_off_range": (0, 8 * 3600),
},
}
[docs]
class LedBrightness(enum.Enum):
"""Note that only Xiaomi Smart Space Heater 1S (zhimi.heater.za2) supports `Dim`."""
On = 0
Off = 1
Dim = 2
[docs]
class HeaterMiotStatus(DeviceStatus):
"""Container for status reports from the Xiaomi Smart Space Heater S and 1S."""
def __init__(self, data: Dict[str, Any], model: str) -> 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
self.model = model
@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 relative_humidity(self) -> Optional[int]:
"""Current relative humidity."""
return self.data.get("relative_humidity")
@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."""
value = self.data["led_brightness"]
if self.model == "zhimi.heater.za2" and value:
value = 3 - value
return LedBrightness(value)
[docs]
class HeaterMiot(MiotDevice):
"""Main class representing the Xiaomi Smart Space Heater S (zhimi.heater.mc2) & 1S
(zhimi.heater.za2)."""
_mappings = _MAPPINGS
[docs]
@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()
},
self.model,
)
[docs]
@command(default_output=format_output("Powering on"))
def on(self):
"""Power on."""
return self.set_property("power", True)
[docs]
@command(default_output=format_output("Powering off"))
def off(self):
"""Power off."""
return self.set_property("power", False)
[docs]
@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.get(
self.model, {"temperature_range": (18, 28)}
)["temperature_range"]
if target_temperature < min_temp or target_temperature > max_temp:
raise ValueError(
"Invalid temperature: %s. Must be between %s and %s."
% (target_temperature, min_temp, max_temp)
)
return self.set_property("target_temperature", target_temperature)
[docs]
@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)
[docs]
@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)
[docs]
@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."""
value = brightness.value
if self.model == "zhimi.heater.za2" and value:
value = 3 - value # Actually 1 means Dim, 2 means Off in za2
elif value == 2:
raise ValueError("Unsupported brightness Dim for model '%s'.", self.model)
return self.set_property("led_brightness", value)
[docs]
@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.get(
self.model, {"delay_off_range": (0, 12 * 3600)}
)["delay_off_range"]
if seconds < min_delay or seconds > max_delay:
raise ValueError(
"Invalid scheduled turn off: %s. Must be between %s and %s"
% (seconds, min_delay, max_delay)
)
return self.set_property("countdown_time", seconds // 3600)