From 781015fb19297733849db0bf4db2063d9e2d0afe Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 28 Jul 2021 10:52:43 +0200 Subject: [PATCH] Xiaomi_Miio Humidifier rework (#52366) Co-authored-by: Martin Hjelmare Co-authored-by: Maciej Bieniek Co-authored-by: Teemu R. Co-authored-by: Franck Nijhof --- .coveragerc | 3 + .../components/xiaomi_miio/__init__.py | 79 ++- homeassistant/components/xiaomi_miio/const.py | 123 ++++- .../components/xiaomi_miio/device.py | 59 +++ homeassistant/components/xiaomi_miio/fan.py | 501 +----------------- .../components/xiaomi_miio/humidifier.py | 372 +++++++++++++ .../components/xiaomi_miio/number.py | 156 ++++++ .../components/xiaomi_miio/select.py | 189 +++++++ .../components/xiaomi_miio/sensor.py | 160 +++++- .../components/xiaomi_miio/switch.py | 264 ++++++++- 10 files changed, 1379 insertions(+), 527 deletions(-) create mode 100644 homeassistant/components/xiaomi_miio/humidifier.py create mode 100644 homeassistant/components/xiaomi_miio/number.py create mode 100644 homeassistant/components/xiaomi_miio/select.py diff --git a/.coveragerc b/.coveragerc index 96bde517482..c3516739798 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1205,8 +1205,11 @@ omit = homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py homeassistant/components/xiaomi_miio/gateway.py + homeassistant/components/xiaomi_miio/humidifier.py homeassistant/components/xiaomi_miio/light.py + homeassistant/components/xiaomi_miio/number.py homeassistant/components/xiaomi_miio/remote.py + homeassistant/components/xiaomi_miio/select.py homeassistant/components/xiaomi_miio/sensor.py homeassistant/components/xiaomi_miio/switch.py homeassistant/components/xiaomi_miio/vacuum.py diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 076aed4d30c..afc6783a0bb 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -2,12 +2,15 @@ from datetime import timedelta import logging +import async_timeout +from miio import AirHumidifier, AirHumidifierMiot, DeviceException from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core from homeassistant.const import CONF_HOST, CONF_TOKEN -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_AVAILABLE, @@ -17,8 +20,12 @@ from .const import ( CONF_MODEL, DOMAIN, KEY_COORDINATOR, + KEY_DEVICE, + KEY_MIGRATE_ENTITY_NAME, MODELS_AIR_MONITOR, MODELS_FAN, + MODELS_HUMIDIFIER, + MODELS_HUMIDIFIER_MIOT, MODELS_LIGHT, MODELS_SWITCH, MODELS_VACUUM, @@ -30,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] FAN_PLATFORMS = ["fan"] +HUMIDIFIER_PLATFORMS = ["humidifier", "number", "select", "sensor", "switch"] LIGHT_PLATFORMS = ["light"] VACUUM_PLATFORMS = ["vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] @@ -51,6 +59,7 @@ async def async_setup_entry( ) +@callback def get_platforms(config_entry): """Return the platforms belonging to a config_entry.""" model = config_entry.data[CONF_MODEL] @@ -61,6 +70,8 @@ def get_platforms(config_entry): if flow_type == CONF_DEVICE: if model in MODELS_SWITCH: return SWITCH_PLATFORMS + if model in MODELS_HUMIDIFIER: + return HUMIDIFIER_PLATFORMS if model in MODELS_FAN: return FAN_PLATFORMS if model in MODELS_LIGHT: @@ -71,10 +82,70 @@ def get_platforms(config_entry): for air_monitor_model in MODELS_AIR_MONITOR: if model.startswith(air_monitor_model): return AIR_MONITOR_PLATFORMS - + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/syssi/xiaomi_airpurifier/issues " + "and provide the following data: %s", + model, + ) return [] +async def async_create_miio_device_and_coordinator( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up a data coordinator and one miio device to service multiple entities.""" + model = entry.data[CONF_MODEL] + host = entry.data[CONF_HOST] + token = entry.data[CONF_TOKEN] + name = entry.title + device = None + migrate_entity_name = None + + if model not in MODELS_HUMIDIFIER: + return + if model in MODELS_HUMIDIFIER_MIOT: + device = AirHumidifierMiot(host, token) + else: + device = AirHumidifier(host, token, model=model) + + # Removing fan platform entity for humidifiers and cache the name and entity name for migration + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) + if entity_id: + # This check is entities that have a platform migration only and should be removed in the future + migrate_entity_name = entity_registry.async_get(entity_id).name + entity_registry.async_remove(entity_id) + + async def async_update_data(): + """Fetch data from the device using async_add_executor_job.""" + try: + async with async_timeout.timeout(10): + return await hass.async_add_executor_job(device.status) + + except DeviceException as ex: + raise UpdateFailed(ex) from ex + + # Create update miio device and coordinator + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=name, + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=60), + ) + hass.data[DOMAIN][entry.entry_id] = { + KEY_DEVICE: device, + KEY_COORDINATOR: coordinator, + } + if migrate_entity_name: + hass.data[DOMAIN][entry.entry_id][KEY_MIGRATE_ENTITY_NAME] = migrate_entity_name + + # Trigger first data fetch + await coordinator.async_config_entry_first_refresh() + + async def async_setup_gateway_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): @@ -130,7 +201,6 @@ async def async_setup_gateway_entry( coordinator = DataUpdateCoordinator( hass, _LOGGER, - # Name of the data. For logging purposes. name=name, update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. @@ -155,6 +225,7 @@ async def async_setup_device_entry( ): """Set up the Xiaomi Miio device component from a config entry.""" platforms = get_platforms(entry) + await async_create_miio_device_and_coordinator(hass, entry) if not platforms: return False diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 27d0a34bf39..05499efb5d3 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -15,10 +15,17 @@ CONF_MANUAL = "manual" # Options flow CONF_CLOUD_SUBDEVICES = "cloud_subdevices" +# Keys KEY_COORDINATOR = "coordinator" +KEY_DEVICE = "device" +KEY_MIGRATE_ENTITY_NAME = "migrate_entity_name" +# Attributes ATTR_AVAILABLE = "available" +# Status +SUCCESS = ["ok"] + # Cloud SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] DEFAULT_CLOUD_COUNTRY = "cn" @@ -70,10 +77,12 @@ MODELS_FAN_MIIO = [ MODEL_AIRPURIFIER_SA2, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H, + MODEL_AIRFRESH_VA2, +] +MODELS_HUMIDIFIER_MIIO = [ MODEL_AIRHUMIDIFIER_V1, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, - MODEL_AIRFRESH_VA2, ] # AirQuality Models @@ -108,7 +117,8 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_FAN_MIIO + MODELS_HUMIDIFIER_MIOT + MODELS_PURIFIER_MIOT +MODELS_FAN = MODELS_FAN_MIIO + MODELS_PURIFIER_MIOT +MODELS_HUMIDIFIER = MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO MODELS_LIGHT = ( MODELS_LIGHT_EYECARE + MODELS_LIGHT_CEILING @@ -125,17 +135,29 @@ MODELS_AIR_MONITOR = [ ] MODELS_ALL_DEVICES = ( - MODELS_SWITCH + MODELS_VACUUM + MODELS_AIR_MONITOR + MODELS_FAN + MODELS_LIGHT + MODELS_SWITCH + + MODELS_VACUUM + + MODELS_AIR_MONITOR + + MODELS_FAN + + MODELS_HUMIDIFIER + + MODELS_LIGHT ) MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY -# Fan Services +# Fan/Humidifier Services SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" +SERVICE_SET_BUZZER = "set_buzzer" +SERVICE_SET_CLEAN_ON = "set_clean_on" +SERVICE_SET_CLEAN_OFF = "set_clean_off" +SERVICE_SET_CLEAN = "set_clean" SERVICE_SET_FAN_LED_ON = "fan_set_led_on" SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" +SERVICE_SET_FAN_LED = "fan_set_led" +SERVICE_SET_LED_BRIGHTNESS = "set_led_brightness" SERVICE_SET_CHILD_LOCK_ON = "fan_set_child_lock_on" SERVICE_SET_CHILD_LOCK_OFF = "fan_set_child_lock_off" +SERVICE_SET_CHILD_LOCK = "set_child_lock" SERVICE_SET_LED_BRIGHTNESS = "fan_set_led_brightness" SERVICE_SET_FAVORITE_LEVEL = "fan_set_favorite_level" SERVICE_SET_FAN_LEVEL = "fan_set_fan_level" @@ -149,6 +171,7 @@ SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" SERVICE_SET_TARGET_HUMIDITY = "fan_set_target_humidity" SERVICE_SET_DRY_ON = "fan_set_dry_on" SERVICE_SET_DRY_OFF = "fan_set_dry_off" +SERVICE_SET_DRY = "set_dry" SERVICE_SET_MOTOR_SPEED = "fan_set_motor_speed" # Light Services @@ -180,3 +203,95 @@ SERVICE_STOP_REMOTE_CONTROL = "vacuum_remote_control_stop" SERVICE_CLEAN_SEGMENT = "vacuum_clean_segment" SERVICE_CLEAN_ZONE = "vacuum_clean_zone" SERVICE_GOTO = "vacuum_goto" + +# Features +FEATURE_SET_BUZZER = 1 +FEATURE_SET_LED = 2 +FEATURE_SET_CHILD_LOCK = 4 +FEATURE_SET_LED_BRIGHTNESS = 8 +FEATURE_SET_FAVORITE_LEVEL = 16 +FEATURE_SET_AUTO_DETECT = 32 +FEATURE_SET_LEARN_MODE = 64 +FEATURE_SET_VOLUME = 128 +FEATURE_RESET_FILTER = 256 +FEATURE_SET_EXTRA_FEATURES = 512 +FEATURE_SET_TARGET_HUMIDITY = 1024 +FEATURE_SET_DRY = 2048 +FEATURE_SET_FAN_LEVEL = 4096 +FEATURE_SET_MOTOR_SPEED = 8192 +FEATURE_SET_CLEAN = 16384 + +FEATURE_FLAGS_AIRPURIFIER = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_LEARN_MODE + | FEATURE_RESET_FILTER + | FEATURE_SET_EXTRA_FEATURES +) + +FEATURE_FLAGS_AIRPURIFIER_PRO = ( + FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_AUTO_DETECT + | FEATURE_SET_VOLUME +) + +FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = ( + FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_VOLUME +) + +FEATURE_FLAGS_AIRPURIFIER_2S = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL +) + +FEATURE_FLAGS_AIRPURIFIER_3 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_FAN_LEVEL + | FEATURE_SET_LED_BRIGHTNESS +) + +FEATURE_FLAGS_AIRPURIFIER_V3 = ( + FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED +) + +FEATURE_FLAGS_AIRHUMIDIFIER = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_TARGET_HUMIDITY +) + +FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY + +FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_TARGET_HUMIDITY + | FEATURE_SET_DRY + | FEATURE_SET_MOTOR_SPEED + | FEATURE_SET_CLEAN +) + +FEATURE_FLAGS_AIRFRESH = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_RESET_FILTER + | FEATURE_SET_EXTRA_FEATURES +) diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index 081b910efdb..f8402138f21 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -1,4 +1,5 @@ """Code to handle a Xiaomi Device.""" +from functools import partial import logging from construct.core import ChecksumError @@ -7,6 +8,7 @@ from miio import Device, DeviceException from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_MAC, CONF_MODEL, DOMAIN @@ -73,6 +75,7 @@ class XiaomiMiioEntity(Entity): self._device_id = entry.unique_id self._unique_id = unique_id self._name = name + self._available = None @property def unique_id(self): @@ -98,3 +101,59 @@ class XiaomiMiioEntity(Entity): device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} return device_info + + +class XiaomiCoordinatedMiioEntity(CoordinatorEntity): + """Representation of a base a coordinated Xiaomi Miio Entity.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the coordinated Xiaomi Miio Device.""" + super().__init__(coordinator) + self._device = device + self._model = entry.data[CONF_MODEL] + self._mac = entry.data[CONF_MAC] + self._device_id = entry.unique_id + self._device_name = entry.title + self._unique_id = unique_id + self._name = name + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def device_info(self): + """Return the device info.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "manufacturer": "Xiaomi", + "name": self._device_name, + "model": self._model, + } + + if self._mac is not None: + device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + + return device_info + + async def _try_command(self, mask_error, func, *args, **kwargs): + """Call a miio device command handling error messages.""" + try: + result = await self.hass.async_add_executor_job( + partial(func, *args, **kwargs) + ) + + _LOGGER.debug("Response received from miio device: %s", result) + + return True + except DeviceException as exc: + if self.available: + _LOGGER.error(mask_error, exc) + + return False diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index bdef9517cca..c58d9ad0c66 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -5,27 +5,11 @@ from functools import partial import logging import math -from miio import ( - AirFresh, - AirHumidifier, - AirHumidifierMiot, - AirPurifier, - AirPurifierMiot, - DeviceException, -) +from miio import AirFresh, AirPurifier, AirPurifierMiot, DeviceException from miio.airfresh import ( LedBrightness as AirfreshLedBrightness, OperationMode as AirfreshOperationMode, ) -from miio.airhumidifier import ( - LedBrightness as AirhumidifierLedBrightness, - OperationMode as AirhumidifierOperationMode, -) -from miio.airhumidifier_miot import ( - LedBrightness as AirhumidifierMiotLedBrightness, - OperationMode as AirhumidifierMiotOperationMode, - PressedButton as AirhumidifierPressedButton, -) from miio.airpurifier import ( LedBrightness as AirpurifierLedBrightness, OperationMode as AirpurifierOperationMode, @@ -38,9 +22,6 @@ import voluptuous as vol from homeassistant.components.fan import ( PLATFORM_SCHEMA, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, @@ -64,16 +45,23 @@ from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, - MODEL_AIRHUMIDIFIER_CA1, - MODEL_AIRHUMIDIFIER_CA4, - MODEL_AIRHUMIDIFIER_CB1, + FEATURE_RESET_FILTER, + FEATURE_SET_AUTO_DETECT, + FEATURE_SET_BUZZER, + FEATURE_SET_CHILD_LOCK, + FEATURE_SET_EXTRA_FEATURES, + FEATURE_SET_FAN_LEVEL, + FEATURE_SET_FAVORITE_LEVEL, + FEATURE_SET_LEARN_MODE, + FEATURE_SET_LED, + FEATURE_SET_LED_BRIGHTNESS, + FEATURE_SET_VOLUME, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, MODELS_FAN, - MODELS_HUMIDIFIER_MIOT, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_AUTO_DETECT_OFF, @@ -82,8 +70,6 @@ from .const import ( SERVICE_SET_BUZZER_ON, SERVICE_SET_CHILD_LOCK_OFF, SERVICE_SET_CHILD_LOCK_ON, - SERVICE_SET_DRY_OFF, - SERVICE_SET_DRY_ON, SERVICE_SET_EXTRA_FEATURES, SERVICE_SET_FAN_LED_OFF, SERVICE_SET_FAN_LED_ON, @@ -92,9 +78,8 @@ from .const import ( SERVICE_SET_LEARN_MODE_OFF, SERVICE_SET_LEARN_MODE_ON, SERVICE_SET_LED_BRIGHTNESS, - SERVICE_SET_MOTOR_SPEED, - SERVICE_SET_TARGET_HUMIDITY, SERVICE_SET_VOLUME, + SUCCESS, ) from .device import XiaomiMiioEntity @@ -150,20 +135,6 @@ ATTR_VOLUME = "volume" ATTR_USE_TIME = "use_time" ATTR_BUTTON_PRESSED = "button_pressed" -# Air Humidifier -ATTR_TARGET_HUMIDITY = "target_humidity" -ATTR_TRANS_LEVEL = "trans_level" -ATTR_HARDWARE_VERSION = "hardware_version" - -# Air Humidifier CA -# ATTR_MOTOR_SPEED = "motor_speed" -ATTR_DEPTH = "depth" -ATTR_DRY = "dry" - -# Air Humidifier CA4 -ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" -ATTR_FAHRENHEIT = "fahrenheit" - # Air Fresh ATTR_CO2 = "co2" @@ -283,41 +254,6 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { ATTR_BUTTON_PRESSED: "button_pressed", } -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON = { - ATTR_TEMPERATURE: "temperature", - ATTR_HUMIDITY: "humidity", - ATTR_MODE: "mode", - ATTR_BUZZER: "buzzer", - ATTR_CHILD_LOCK: "child_lock", - ATTR_TARGET_HUMIDITY: "target_humidity", - ATTR_LED_BRIGHTNESS: "led_brightness", - ATTR_USE_TIME: "use_time", -} - -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_TRANS_LEVEL: "trans_level", - ATTR_BUTTON_PRESSED: "button_pressed", - ATTR_HARDWARE_VERSION: "hardware_version", -} - -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA_AND_CB = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_MOTOR_SPEED: "motor_speed", - ATTR_DEPTH: "depth", - ATTR_DRY: "dry", - ATTR_HARDWARE_VERSION: "hardware_version", -} - -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA4 = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", - ATTR_BUTTON_PRESSED: "button_pressed", - ATTR_DRY: "dry", - ATTR_FAHRENHEIT: "fahrenheit", - ATTR_MOTOR_SPEED: "motor_speed", -} - AVAILABLE_ATTRIBUTES_AIRFRESH = { ATTR_TEMPERATURE: "temperature", ATTR_AIR_QUALITY_INDEX: "aqi", @@ -365,25 +301,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ ] OPERATION_MODES_AIRFRESH = ["Auto", "Silent", "Interval", "Low", "Middle", "Strong"] PRESET_MODES_AIRFRESH = ["Auto", "Interval"] -PRESET_MODES_AIRHUMIDIFIER = ["Auto"] -PRESET_MODES_AIRHUMIDIFIER_CA4 = ["Auto"] - -SUCCESS = ["ok"] - -FEATURE_SET_BUZZER = 1 -FEATURE_SET_LED = 2 -FEATURE_SET_CHILD_LOCK = 4 -FEATURE_SET_LED_BRIGHTNESS = 8 -FEATURE_SET_FAVORITE_LEVEL = 16 -FEATURE_SET_AUTO_DETECT = 32 -FEATURE_SET_LEARN_MODE = 64 -FEATURE_SET_VOLUME = 128 -FEATURE_RESET_FILTER = 256 -FEATURE_SET_EXTRA_FEATURES = 512 -FEATURE_SET_TARGET_HUMIDITY = 1024 -FEATURE_SET_DRY = 2048 -FEATURE_SET_FAN_LEVEL = 4096 -FEATURE_SET_MOTOR_SPEED = 8192 FEATURE_FLAGS_AIRPURIFIER = ( FEATURE_SET_BUZZER @@ -431,25 +348,6 @@ FEATURE_FLAGS_AIRPURIFIER_V3 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED ) -FEATURE_FLAGS_AIRHUMIDIFIER = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_TARGET_HUMIDITY -) - -FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY - -FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_TARGET_HUMIDITY - | FEATURE_SET_DRY - | FEATURE_SET_MOTOR_SPEED -) - FEATURE_FLAGS_AIRFRESH = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK @@ -481,22 +379,6 @@ SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( {vol.Required(ATTR_FEATURES): cv.positive_int} ) -SERVICE_SCHEMA_TARGET_HUMIDITY = AIRPURIFIER_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_HUMIDITY): vol.All( - vol.Coerce(int), vol.In([30, 40, 50, 60, 70, 80]) - ) - } -) - -SERVICE_SCHEMA_MOTOR_SPEED = AIRPURIFIER_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_MOTOR_SPEED): vol.All( - vol.Coerce(int), vol.Clamp(min=200, max=2000) - ) - } -) - SERVICE_TO_METHOD = { SERVICE_SET_BUZZER_ON: {"method": "async_set_buzzer_on"}, SERVICE_SET_BUZZER_OFF: {"method": "async_set_buzzer_off"}, @@ -526,16 +408,6 @@ SERVICE_TO_METHOD = { "method": "async_set_extra_features", "schema": SERVICE_SCHEMA_EXTRA_FEATURES, }, - SERVICE_SET_TARGET_HUMIDITY: { - "method": "async_set_target_humidity", - "schema": SERVICE_SCHEMA_TARGET_HUMIDITY, - }, - SERVICE_SET_DRY_ON: {"method": "async_set_dry_on"}, - SERVICE_SET_DRY_OFF: {"method": "async_set_dry_off"}, - SERVICE_SET_MOTOR_SPEED: { - "method": "async_set_motor_speed", - "schema": SERVICE_SCHEMA_MOTOR_SPEED, - }, } @@ -578,14 +450,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): elif model.startswith("zhimi.airpurifier."): air_purifier = AirPurifier(host, token) entity = XiaomiAirPurifier(name, air_purifier, config_entry, unique_id) - elif model in MODELS_HUMIDIFIER_MIOT: - air_humidifier = AirHumidifierMiot(host, token) - entity = XiaomiAirHumidifierMiot( - name, air_humidifier, config_entry, unique_id - ) - elif model.startswith("zhimi.humidifier."): - air_humidifier = AirHumidifier(host, token, model=model) - entity = XiaomiAirHumidifier(name, air_humidifier, config_entry, unique_id) elif model.startswith("zhimi.airfresh."): air_fresh = AirFresh(host, token) entity = XiaomiAirFresh(name, air_fresh, config_entry, unique_id) @@ -1247,345 +1111,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): ) -class XiaomiAirHumidifier(XiaomiGenericDevice): - """Representation of a Xiaomi Air Humidifier.""" - - SPEED_MODE_MAPPING = { - 1: AirhumidifierOperationMode.Silent, - 2: AirhumidifierOperationMode.Medium, - 3: AirhumidifierOperationMode.High, - 4: AirhumidifierOperationMode.Strong, - } - - REVERSE_SPEED_MODE_MAPPING = {v: k for k, v in SPEED_MODE_MAPPING.items()} - - PRESET_MODE_MAPPING = { - "Auto": AirhumidifierOperationMode.Auto, - } - - def __init__(self, name, device, entry, unique_id): - """Initialize the plug switch.""" - super().__init__(name, device, entry, unique_id) - self._percentage = None - self._preset_mode = None - self._supported_features = SUPPORT_SET_SPEED - self._preset_modes = [] - if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA_AND_CB - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Strong - ] - self._supported_features |= SUPPORT_PRESET_MODE - self._preset_modes = PRESET_MODES_AIRHUMIDIFIER - self._speed_count = 3 - elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: - self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA4 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - self._supported_features |= SUPPORT_PRESET_MODE - self._preset_modes = PRESET_MODES_AIRHUMIDIFIER - self._speed_count = 3 - else: - self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Auto - ] - self._supported_features |= SUPPORT_PRESET_MODE - self._preset_modes = PRESET_MODES_AIRHUMIDIFIER - self._speed_count = 4 - - self._state_attrs.update( - {attribute: None for attribute in self._available_attributes} - ) - - async def async_update(self): - """Fetch state from the device.""" - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = await self.hass.async_add_executor_job(self._device.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(state, value) - for key, value in self._available_attributes.items() - } - ) - - except DeviceException as ex: - if self._available: - self._available = False - _LOGGER.error("Got exception while fetching the state: %s", ex) - - @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = AirhumidifierOperationMode(self._state_attrs[ATTR_MODE]).name - return preset_mode if preset_mode in self._preset_modes else None - - return None - - @property - def percentage(self): - """Return the current percentage based speed.""" - if self._state: - mode = AirhumidifierOperationMode(self._state_attrs[ATTR_MODE]) - if mode in self.REVERSE_SPEED_MODE_MAPPING: - return ranged_value_to_percentage( - (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] - ) - - return None - - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirhumidifierOperationMode(self._state_attrs[ATTR_MODE]).name - - return None - - async def async_set_percentage(self, percentage: int) -> None: - """Set the percentage of the fan. - - This method is a coroutine. - """ - speed_mode = math.ceil( - percentage_to_ranged_value((1, self._speed_count), percentage) - ) - if speed_mode: - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirhumidifierOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode of the fan. - - This method is a coroutine. - """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], - ) - - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirhumidifierOperationMode[speed.title()], - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirhumidifierLedBrightness(brightness), - ) - - async def async_set_target_humidity(self, humidity: int = 40): - """Set the target humidity.""" - if self._device_features & FEATURE_SET_TARGET_HUMIDITY == 0: - return - - await self._try_command( - "Setting the target humidity of the miio device failed.", - self._device.set_target_humidity, - humidity, - ) - - async def async_set_dry_on(self): - """Turn the dry mode on.""" - if self._device_features & FEATURE_SET_DRY == 0: - return - - await self._try_command( - "Turning the dry mode of the miio device off failed.", - self._device.set_dry, - True, - ) - - async def async_set_dry_off(self): - """Turn the dry mode off.""" - if self._device_features & FEATURE_SET_DRY == 0: - return - - await self._try_command( - "Turning the dry mode of the miio device off failed.", - self._device.set_dry, - False, - ) - - -class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): - """Representation of a Xiaomi Air Humidifier (MiOT protocol).""" - - PRESET_MODE_MAPPING = { - AirhumidifierMiotOperationMode.Auto: "Auto", - } - - REVERSE_PRESET_MODE_MAPPING = {v: k for k, v in PRESET_MODE_MAPPING.items()} - - SPEED_MAPPING = { - AirhumidifierMiotOperationMode.Low: SPEED_LOW, - AirhumidifierMiotOperationMode.Mid: SPEED_MEDIUM, - AirhumidifierMiotOperationMode.High: SPEED_HIGH, - } - - REVERSE_SPEED_MAPPING = {v: k for k, v in SPEED_MAPPING.items()} - - SPEEDS = [ - AirhumidifierMiotOperationMode.Low, - AirhumidifierMiotOperationMode.Mid, - AirhumidifierMiotOperationMode.High, - ] - - # the speed attribute is deprecated, support will end with release 2021.7 - # it is added here for compatibility - @property - def speed(self): - """Return current legacy speed.""" - if ( - self.state - and AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - in self.SPEED_MAPPING - ): - return self.SPEED_MAPPING[ - AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - ] - return None - - @property - def percentage(self): - """Return the current percentage based speed.""" - if ( - self.state - and AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - in self.SPEEDS - ): - return ranged_value_to_percentage( - (1, self.speed_count), self._state_attrs[ATTR_MODE] - ) - - return None - - @property - def preset_mode(self): - """Return the current preset_mode.""" - if self._state: - mode = self.PRESET_MODE_MAPPING.get( - AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - ) - if mode in self._preset_modes: - return mode - - return None - - @property - def button_pressed(self): - """Return the last button pressed.""" - if self._state: - return AirhumidifierPressedButton( - self._state_attrs[ATTR_BUTTON_PRESSED] - ).name - - return None - - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Override for set async_set_speed of the super() class.""" - if speed and speed in self.REVERSE_SPEED_MAPPING: - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - self.REVERSE_SPEED_MAPPING[speed], - ) - - async def async_set_percentage(self, percentage: int) -> None: - """Set the percentage of the fan. - - This method is a coroutine. - """ - mode = math.ceil(percentage_to_ranged_value((1, 3), percentage)) - if mode: - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirhumidifierMiotOperationMode(mode), - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode of the fan. - - This method is a coroutine. - """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - self.REVERSE_PRESET_MODE_MAPPING[preset_mode], - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirhumidifierMiotLedBrightness(brightness), - ) - - async def async_set_motor_speed(self, motor_speed: int = 400): - """Set the target motor speed.""" - if self._device_features & FEATURE_SET_MOTOR_SPEED == 0: - return - - await self._try_command( - "Setting the target motor speed of the miio device failed.", - self._device.set_speed, - motor_speed, - ) - - class XiaomiAirFresh(XiaomiGenericDevice): """Representation of a Xiaomi Air Fresh.""" diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py new file mode 100644 index 00000000000..1535a8772ab --- /dev/null +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -0,0 +1,372 @@ +"""Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity.""" +from enum import Enum +import logging +import math + +from miio.airhumidifier import OperationMode as AirhumidifierOperationMode +from miio.airhumidifier_miot import OperationMode as AirhumidifierMiotOperationMode + +from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier.const import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_HUMIDIFIER, + SUPPORT_MODES, +) +from homeassistant.const import ATTR_MODE, CONF_HOST, CONF_TOKEN +from homeassistant.core import callback +from homeassistant.util.percentage import percentage_to_ranged_value + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, + KEY_MIGRATE_ENTITY_NAME, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODELS_HUMIDIFIER_MIOT, +) +from .device import XiaomiCoordinatedMiioEntity + +_LOGGER = logging.getLogger(__name__) + +# Air Humidifier +ATTR_TARGET_HUMIDITY = "target_humidity" + +AVAILABLE_ATTRIBUTES = { + ATTR_MODE: "mode", + ATTR_TARGET_HUMIDITY: "target_humidity", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Humidifier from a config entry.""" + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return + + entities = [] + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: + name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] + else: + name = config_entry.title + + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + + if model in MODELS_HUMIDIFIER_MIOT: + air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + entity = XiaomiAirHumidifierMiot( + name, + air_humidifier, + config_entry, + unique_id, + coordinator, + ) + else: + air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + entity = XiaomiAirHumidifier( + name, + air_humidifier, + config_entry, + unique_id, + coordinator, + ) + + entities.append(entity) + + async_add_entities(entities) + + +class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): + """Representation of a generic Xiaomi humidifier device.""" + + _attr_device_class = DEVICE_CLASS_HUMIDIFIER + _attr_supported_features = SUPPORT_MODES + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the generic Xiaomi device.""" + super().__init__(name, device, entry, unique_id, coordinator=coordinator) + + self._state = None + self._attributes = {} + self._available_modes = [] + self._mode = None + self._min_humidity = DEFAULT_MIN_HUMIDITY + self._max_humidity = DEFAULT_MAX_HUMIDITY + self._humidity_steps = 100 + self._target_humidity = None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + @property + def available_modes(self) -> list: + """Get the list of available modes.""" + return self._available_modes + + @property + def mode(self): + """Get the current mode.""" + return self._mode + + @property + def min_humidity(self): + """Return the minimum target humidity.""" + return self._min_humidity + + @property + def max_humidity(self): + """Return the maximum target humidity.""" + return self._max_humidity + + async def async_turn_on( + self, + **kwargs, + ) -> None: + """Turn the device on.""" + result = await self._try_command( + "Turning the miio device on failed.", self._device.on + ) + if result: + self._state = True + + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + result = await self._try_command( + "Turning the miio device off failed.", self._device.off + ) + + if result: + self._state = False + + def translate_humidity(self, humidity): + """Translate the target humidity to the first valid step.""" + return ( + math.ceil(percentage_to_ranged_value((1, self._humidity_steps), humidity)) + * 100 + / self._humidity_steps + if 0 < humidity <= 100 + else None + ) + + +class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): + """Representation of a Xiaomi Air Humidifier.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, coordinator) + if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + self._available_modes = [] + self._available_modes = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Strong + ] + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 10 + elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: + self._available_modes = [ + mode.name for mode in AirhumidifierMiotOperationMode + ] + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 100 + else: + self._available_modes = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Auto + ] + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 10 + + self._state = self.coordinator.data.is_on + self._attributes.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in AVAILABLE_ATTRIBUTES.items() + } + ) + self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] + self._mode = self._attributes[ATTR_MODE] + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._attributes.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in AVAILABLE_ATTRIBUTES.items() + } + ) + self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] + self._mode = self._attributes[ATTR_MODE] + self.async_write_ha_state() + + @property + def mode(self): + """Return the current mode.""" + return AirhumidifierOperationMode(self._mode).name + + @property + def target_humidity(self): + """Return the target humidity.""" + return ( + self._target_humidity + if self._mode == AirhumidifierOperationMode.Auto.name + or AirhumidifierOperationMode.Auto.name not in self.available_modes + else None + ) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier and set the mode to auto.""" + target_humidity = self.translate_humidity(humidity) + if not target_humidity: + return + + _LOGGER.debug("Setting the target humidity to: %s", target_humidity) + if await self._try_command( + "Setting target humidity of the miio device failed.", + self._device.set_target_humidity, + target_humidity, + ): + self._target_humidity = target_humidity + if ( + self.supported_features & SUPPORT_MODES == 0 + or AirhumidifierOperationMode(self._attributes[ATTR_MODE]) + == AirhumidifierOperationMode.Auto + or AirhumidifierOperationMode.Auto.name not in self.available_modes + ): + self.async_write_ha_state() + return + _LOGGER.debug("Setting the operation mode to: Auto") + if await self._try_command( + "Setting operation mode of the miio device to MODE_AUTO failed.", + self._device.set_mode, + AirhumidifierOperationMode.Auto, + ): + self._mode = AirhumidifierOperationMode.Auto.name + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the humidifier.""" + if self.supported_features & SUPPORT_MODES == 0 or not mode: + return + + if mode not in self.available_modes: + _LOGGER.warning("Mode %s is not a valid operation mode", mode) + return + + _LOGGER.debug("Setting the operation mode to: %s", mode) + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + AirhumidifierOperationMode[mode.title()], + ): + self._mode = mode.title() + self.async_write_ha_state() + + +class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): + """Representation of a Xiaomi Air Humidifier (MiOT protocol).""" + + MODE_MAPPING = { + AirhumidifierMiotOperationMode.Auto: "Auto", + AirhumidifierMiotOperationMode.Low: "Low", + AirhumidifierMiotOperationMode.Mid: "Mid", + AirhumidifierMiotOperationMode.High: "High", + } + + REVERSE_MODE_MAPPING = {v: k for k, v in MODE_MAPPING.items()} + + @property + def mode(self): + """Return the current mode.""" + return AirhumidifierMiotOperationMode(self._mode).name + + @property + def target_humidity(self): + """Return the target humidity.""" + if self._state: + return ( + self._target_humidity + if AirhumidifierMiotOperationMode(self._mode) + == AirhumidifierMiotOperationMode.Auto + else None + ) + return None + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier and set the mode to auto.""" + target_humidity = self.translate_humidity(humidity) + if not target_humidity: + return + + _LOGGER.debug("Setting the humidity to: %s", target_humidity) + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_target_humidity, + target_humidity, + ): + self._target_humidity = target_humidity + if ( + self.supported_features & SUPPORT_MODES == 0 + or AirhumidifierMiotOperationMode(self._attributes[ATTR_MODE]) + == AirhumidifierMiotOperationMode.Auto + ): + self.async_write_ha_state() + return + _LOGGER.debug("Setting the operation mode to: Auto") + if await self._try_command( + "Setting operation mode of the miio device to MODE_AUTO failed.", + self._device.set_mode, + AirhumidifierMiotOperationMode.Auto, + ): + self._mode = 0 + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the fan.""" + if self.supported_features & SUPPORT_MODES == 0 or not mode: + return + + if mode not in self.REVERSE_MODE_MAPPING: + _LOGGER.warning("Mode %s is not a valid operation mode", mode) + return + + _LOGGER.debug("Setting the operation mode to: %s", mode) + if self._state: + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + self.REVERSE_MODE_MAPPING[mode], + ): + self._mode = self.REVERSE_MODE_MAPPING[mode].value + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py new file mode 100644 index 00000000000..f81f46967ab --- /dev/null +++ b/homeassistant/components/xiaomi_miio/number.py @@ -0,0 +1,156 @@ +"""Motor speed support for Xiaomi Mi Air Humidifier.""" +from dataclasses import dataclass +from enum import Enum +import logging + +from homeassistant.components.number import NumberEntity +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import callback + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + FEATURE_SET_MOTOR_SPEED, + KEY_COORDINATOR, + KEY_DEVICE, + KEY_MIGRATE_ENTITY_NAME, + MODEL_AIRHUMIDIFIER_CA4, +) +from .device import XiaomiCoordinatedMiioEntity + +_LOGGER = logging.getLogger(__name__) + +ATTR_MOTOR_SPEED = "motor_speed" + + +@dataclass +class NumberType: + """Class that holds device specific info for a xiaomi aqara or humidifier number controller types.""" + + name: str = None + short_name: str = None + unit_of_measurement: str = None + icon: str = None + device_class: str = None + min: float = None + max: float = None + step: float = None + available_with_device_off: bool = True + + +NUMBER_TYPES = { + FEATURE_SET_MOTOR_SPEED: NumberType( + name="Motor Speed", + icon="mdi:fast-forward-outline", + short_name=ATTR_MOTOR_SPEED, + unit_of_measurement="rpm", + min=200, + max=2000, + step=10, + available_with_device_off=False, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Selectors from a config entry.""" + entities = [] + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + model = config_entry.data[CONF_MODEL] + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: + name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] + else: + name = config_entry.title + + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + if model not in [MODEL_AIRHUMIDIFIER_CA4]: + return + + for number in NUMBER_TYPES.values(): + entities.append( + XiaomiAirHumidifierNumber( + f"{name} {number.name}", + device, + config_entry, + f"{number.short_name}_{config_entry.unique_id}", + number, + coordinator, + ) + ) + + async_add_entities(entities) + + +class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): + """Representation of a generic Xiaomi attribute selector.""" + + def __init__(self, name, device, entry, unique_id, number, coordinator): + """Initialize the generic Xiaomi attribute selector.""" + super().__init__(name, device, entry, unique_id, coordinator) + self._attr_icon = number.icon + self._attr_unit_of_measurement = number.unit_of_measurement + self._attr_min_value = number.min + self._attr_max_value = number.max + self._attr_step = number.step + self._controller = number + self._attr_value = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + + @property + def available(self): + """Return the number controller availability.""" + if ( + super().available + and not self.coordinator.data.is_on + and not self._controller.available_with_device_off + ): + return False + return super().available + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + async def async_set_value(self, value): + """Set an option of the miio device.""" + if ( + self.min_value + and value < self.min_value + or self.max_value + and value > self.max_value + ): + raise ValueError( + f"Value {value} not a valid {self.name} within the range {self.min_value} - {self.max_value}" + ) + if await self.async_set_motor_speed(value): + self._attr_value = value + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + self._attr_value = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + self.async_write_ha_state() + + async def async_set_motor_speed(self, motor_speed: int = 400): + """Set the target motor speed.""" + return await self._try_command( + "Setting the target motor speed of the miio device failed.", + self._device.set_speed, + motor_speed, + ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py new file mode 100644 index 00000000000..40a236aab6a --- /dev/null +++ b/homeassistant/components/xiaomi_miio/select.py @@ -0,0 +1,189 @@ +"""Support led_brightness for Mi Air Humidifier.""" +from dataclasses import dataclass +from enum import Enum +import logging + +from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness +from miio.airhumidifier_miot import LedBrightness as AirhumidifierMiotLedBrightness + +from homeassistant.components.select import SelectEntity +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import callback + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + FEATURE_SET_LED_BRIGHTNESS, + KEY_COORDINATOR, + KEY_DEVICE, + KEY_MIGRATE_ENTITY_NAME, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODELS_HUMIDIFIER, + SERVICE_SET_LED_BRIGHTNESS, +) +from .device import XiaomiCoordinatedMiioEntity + +_LOGGER = logging.getLogger(__name__) + +ATTR_LED_BRIGHTNESS = "led_brightness" + + +LED_BRIGHTNESS_MAP = {"Bright": 0, "Dim": 1, "Off": 2} +LED_BRIGHTNESS_MAP_MIOT = {"Bright": 2, "Dim": 1, "Off": 0} +LED_BRIGHTNESS_REVERSE_MAP = {val: key for key, val in LED_BRIGHTNESS_MAP.items()} +LED_BRIGHTNESS_REVERSE_MAP_MIOT = { + val: key for key, val in LED_BRIGHTNESS_MAP_MIOT.items() +} + + +@dataclass +class SelectorType: + """Class that holds device specific info for a xiaomi aqara or humidifier selectors.""" + + name: str = None + icon: str = None + short_name: str = None + options: list = None + service: str = None + + +SELECTOR_TYPES = { + FEATURE_SET_LED_BRIGHTNESS: SelectorType( + name="Led brightness", + icon="mdi:brightness-6", + short_name=ATTR_LED_BRIGHTNESS, + options=["Bright", "Dim", "Off"], + service=SERVICE_SET_LED_BRIGHTNESS, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Selectors from a config entry.""" + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return + + entities = [] + host = config_entry.data[CONF_HOST] + token = config_entry.data[CONF_TOKEN] + model = config_entry.data[CONF_MODEL] + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: + name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] + else: + name = config_entry.title + + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + + if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + entity_class = XiaomiAirHumidifierSelector + elif model in [MODEL_AIRHUMIDIFIER_CA4]: + entity_class = XiaomiAirHumidifierMiotSelector + elif model in MODELS_HUMIDIFIER: + entity_class = XiaomiAirHumidifierSelector + else: + return + + for selector in SELECTOR_TYPES.values(): + entities.append( + entity_class( + f"{name} {selector.name}", + device, + config_entry, + f"{selector.short_name}_{config_entry.unique_id}", + selector, + coordinator, + ) + ) + + async_add_entities(entities) + + +class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): + """Representation of a generic Xiaomi attribute selector.""" + + def __init__(self, name, device, entry, unique_id, selector, coordinator): + """Initialize the generic Xiaomi attribute selector.""" + super().__init__(name, device, entry, unique_id, coordinator) + self._attr_icon = selector.icon + self._controller = selector + self._attr_options = self._controller.options + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + +class XiaomiAirHumidifierSelector(XiaomiSelector): + """Representation of a Xiaomi Air Humidifier selector.""" + + def __init__(self, name, device, entry, unique_id, controller, coordinator): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, controller, coordinator) + self._current_led_brightness = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._current_led_brightness = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + self.async_write_ha_state() + + @property + def current_option(self): + """Return the current option.""" + return self.led_brightness + + async def async_select_option(self, option: str) -> None: + """Set an option of the miio device.""" + if option not in self.options: + raise ValueError( + f"Selection '{option}' is not a valid {self._controller.name}" + ) + await self.async_set_led_brightness(option) + + @property + def led_brightness(self): + """Return the current led brightness.""" + return LED_BRIGHTNESS_REVERSE_MAP.get(self._current_led_brightness) + + async def async_set_led_brightness(self, brightness: str): + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirhumidifierLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiAirHumidifierMiotSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Humidifier (MiOT protocol) selector.""" + + @property + def led_brightness(self): + """Return the current led brightness.""" + return LED_BRIGHTNESS_REVERSE_MAP_MIOT.get(self._current_led_brightness) + + async def async_set_led_brightness(self, brightness: str): + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirhumidifierMiotLedBrightness(LED_BRIGHTNESS_MAP_MIOT[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP_MIOT[brightness] + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 5d271a772b9..3c28d8496e7 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -1,5 +1,6 @@ -"""Support for Xiaomi Mi Air Quality Monitor (PM2.5).""" +"""Support for Xiaomi Mi Air Quality Monitor (PM2.5) and Humidifier.""" from dataclasses import dataclass +from enum import Enum import logging from miio import AirQualityMonitor, DeviceException @@ -20,6 +21,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_BATTERY_LEVEL, + ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, CONF_TOKEN, @@ -35,8 +37,18 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -from .const import CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, KEY_COORDINATOR -from .device import XiaomiMiioEntity +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_GATEWAY, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, + KEY_MIGRATE_ENTITY_NAME, + MODELS_HUMIDIFIER_MIOT, +) +from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice _LOGGER = logging.getLogger(__name__) @@ -59,42 +71,69 @@ ATTR_NIGHT_MODE = "night_mode" ATTR_NIGHT_TIME_BEGIN = "night_time_begin" ATTR_NIGHT_TIME_END = "night_time_end" ATTR_SENSOR_STATE = "sensor_state" - -SUCCESS = ["ok"] +ATTR_WATER_LEVEL = "water_level" +ATTR_HUMIDITY = "humidity" +ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" @dataclass class SensorType: - """Class that holds device specific info for a xiaomi aqara sensor.""" + """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" unit: str = None icon: str = None device_class: str = None state_class: str = None + valid_min_value: float = None + valid_max_value: float = None -GATEWAY_SENSOR_TYPES = { +SENSOR_TYPES = { "temperature": SensorType( unit=TEMP_CELSIUS, - icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), "humidity": SensorType( unit=PERCENTAGE, - icon=None, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), "pressure": SensorType( unit=PRESSURE_HPA, - icon=None, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), "load_power": SensorType( - unit=POWER_WATT, icon=None, device_class=DEVICE_CLASS_POWER + unit=POWER_WATT, + device_class=DEVICE_CLASS_POWER, ), + "water_level": SensorType( + unit=PERCENTAGE, + icon="mdi:water-check", + state_class=STATE_CLASS_MEASUREMENT, + valid_min_value=0.0, + valid_max_value=100.0, + ), + "actual_speed": SensorType( + unit="rpm", + icon="mdi:fast-forward", + state_class=STATE_CLASS_MEASUREMENT, + valid_min_value=200.0, + valid_max_value=2000.0, + ), +} + +HUMIDIFIER_SENSORS = { + ATTR_HUMIDITY: "humidity", + ATTR_TEMPERATURE: "temperature", +} + +HUMIDIFIER_SENSORS_MIOT = { + ATTR_HUMIDITY: "humidity", + ATTR_TEMPERATURE: "temperature", + ATTR_WATER_LEVEL: "water_level", + ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", } @@ -135,7 +174,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sub_devices = gateway.devices coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for sub_device in sub_devices.values(): - sensor_variables = set(sub_device.status) & set(GATEWAY_SENSOR_TYPES) + sensor_variables = set(sub_device.status) & set(SENSOR_TYPES) if sensor_variables: entities.extend( [ @@ -145,19 +184,90 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for variable in sensor_variables ] ) - - if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + elif config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] - name = config_entry.title - unique_id = config_entry.unique_id + model = config_entry.data[CONF_MODEL] + device = None + sensors = [] + if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: + name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] + else: + name = config_entry.title - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + if model in MODELS_HUMIDIFIER_MIOT: + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + sensors = HUMIDIFIER_SENSORS_MIOT + elif model.startswith("zhimi.humidifier."): + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + sensors = HUMIDIFIER_SENSORS + else: + unique_id = config_entry.unique_id + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) - device = AirQualityMonitor(host, token) - entities.append(XiaomiAirQualityMonitor(name, device, config_entry, unique_id)) + device = AirQualityMonitor(host, token) + entities.append( + XiaomiAirQualityMonitor(name, device, config_entry, unique_id) + ) + for sensor in sensors: + entities.append( + XiaomiGenericSensor( + f"{name} {sensor.replace('_', ' ').title()}", + device, + config_entry, + f"{sensor}_{config_entry.unique_id}", + sensor, + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + ) + ) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) + + +class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): + """Representation of a Xiaomi Humidifier sensor.""" + + def __init__(self, name, device, entry, unique_id, attribute, coordinator): + """Initialize the entity.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._sensor_config = SENSOR_TYPES[attribute] + self._attr_device_class = self._sensor_config.device_class + self._attr_state_class = self._sensor_config.state_class + self._attr_icon = self._sensor_config.icon + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_unit_of_measurement = self._sensor_config.unit + self._device = device + self._entry = entry + self._attribute = attribute + self._state = None + + @property + def state(self): + """Return the state of the device.""" + self._state = self._extract_value_from_attribute( + self.coordinator.data, self._attribute + ) + if ( + self._sensor_config.valid_min_value + and self._state < self._sensor_config.valid_min_value + ) or ( + self._sensor_config.valid_max_value + and self._state > self._sensor_config.valid_max_value + ): + return None + return self._state + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): @@ -189,7 +299,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): @property def icon(self): - """Return the icon to use for device if any.""" + """Return the icon to use in the frontend.""" return self._icon @property @@ -247,22 +357,22 @@ class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): @property def icon(self): """Return the icon to use in the frontend.""" - return GATEWAY_SENSOR_TYPES[self._data_key].icon + return SENSOR_TYPES[self._data_key].icon @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return GATEWAY_SENSOR_TYPES[self._data_key].unit + return SENSOR_TYPES[self._data_key].unit @property def device_class(self): """Return the device class of this entity.""" - return GATEWAY_SENSOR_TYPES[self._data_key].device_class + return SENSOR_TYPES[self._data_key].device_class @property def state_class(self): """Return the state class of this entity.""" - return GATEWAY_SENSOR_TYPES[self._data_key].state_class + return SENSOR_TYPES[self._data_key].state_class @property def state(self): diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 5802ae2a00d..35ac4c7da4f 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,5 +1,7 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" import asyncio +from dataclasses import dataclass +from enum import Enum from functools import partial import logging @@ -21,6 +23,7 @@ from homeassistant.const import ( CONF_NAME, CONF_TOKEN, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( @@ -29,13 +32,31 @@ from .const import ( CONF_GATEWAY, CONF_MODEL, DOMAIN, + FEATURE_FLAGS_AIRHUMIDIFIER, + FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + FEATURE_SET_BUZZER, + FEATURE_SET_CHILD_LOCK, + FEATURE_SET_CLEAN, + FEATURE_SET_DRY, KEY_COORDINATOR, + KEY_DEVICE, + KEY_MIGRATE_ENTITY_NAME, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODELS_HUMIDIFIER, + SERVICE_SET_BUZZER, + SERVICE_SET_CHILD_LOCK, + SERVICE_SET_CLEAN, + SERVICE_SET_DRY, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, SERVICE_SET_WIFI_LED_ON, + SUCCESS, ) -from .device import XiaomiMiioEntity +from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice _LOGGER = logging.getLogger(__name__) @@ -83,8 +104,10 @@ ATTR_POWER_MODE = "power_mode" ATTR_WIFI_LED = "wifi_led" ATTR_POWER_PRICE = "power_price" ATTR_PRICE = "price" - -SUCCESS = ["ok"] +ATTR_BUZZER = "buzzer" +ATTR_CHILD_LOCK = "child_lock" +ATTR_DRY = "dry" +ATTR_CLEAN = "clean_mode" FEATURE_SET_POWER_MODE = 1 FEATURE_SET_WIFI_LED = 2 @@ -121,6 +144,62 @@ SERVICE_TO_METHOD = { "method": "async_set_power_price", "schema": SERVICE_SCHEMA_POWER_PRICE, }, + SERVICE_SET_BUZZER: { + "method_on": "async_set_buzzer_on", + "method_off": "async_set_buzzer_off", + }, + SERVICE_SET_CHILD_LOCK: { + "method_on": "async_set_child_lock_on", + "method_off": "async_set_child_lock_off", + }, + SERVICE_SET_DRY: { + "method_on": "async_set_dry_on", + "method_off": "async_set_dry_off", + }, + SERVICE_SET_CLEAN: { + "method_on": "async_set_clean_on", + "method_off": "async_set_clean_off", + }, +} + + +@dataclass +class SwitchType: + """Class that holds device specific info for a xiaomi aqara or humidifiers.""" + + name: str = None + short_name: str = None + icon: str = None + service: str = None + available_with_device_off: bool = True + + +SWITCH_TYPES = { + FEATURE_SET_BUZZER: SwitchType( + name="Buzzer", + icon="mdi:volume-high", + short_name=ATTR_BUZZER, + service=SERVICE_SET_BUZZER, + ), + FEATURE_SET_CHILD_LOCK: SwitchType( + name="Child Lock", + icon="mdi:lock", + short_name=ATTR_CHILD_LOCK, + service=SERVICE_SET_CHILD_LOCK, + ), + FEATURE_SET_DRY: SwitchType( + name="Dry Mode", + icon="mdi:hair-dryer", + short_name=ATTR_DRY, + service=SERVICE_SET_DRY, + ), + FEATURE_SET_CLEAN: SwitchType( + name="Clean Mode", + icon="mdi:sparkles", + short_name=ATTR_CLEAN, + service=SERVICE_SET_CLEAN, + available_with_device_off=False, + ), } @@ -140,14 +219,63 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the switch from a config entry.""" - entities = [] + if ( + config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY + or config_entry.data[CONF_MODEL] == "lumi.acpartner.v3" + ): + await async_setup_other_entry(hass, config_entry, async_add_entities) + else: + await async_setup_coordinated_entry(hass, config_entry, async_add_entities) + +async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): + """Set up the coordinated switch from a config entry.""" + entities = [] + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + if KEY_MIGRATE_ENTITY_NAME in hass.data[DOMAIN][config_entry.entry_id]: + name = hass.data[DOMAIN][config_entry.entry_id][KEY_MIGRATE_ENTITY_NAME] + else: + name = config_entry.title + + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + + device_features = 0 + + if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB + elif model in [MODEL_AIRHUMIDIFIER_CA4]: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 + elif model in MODELS_HUMIDIFIER: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER + + for feature, switch in SWITCH_TYPES.items(): + if feature & device_features: + entities.append( + XiaomiGenericCoordinatedSwitch( + f"{name} {switch.name}", + device, + config_entry, + f"{switch.short_name}_{unique_id}", + switch, + coordinator, + ) + ) + + async_add_entities(entities) + + +async def async_setup_other_entry(hass, config_entry, async_add_entities): + """Set up the other type switch from a config entry.""" + entities = [] host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] name = config_entry.title model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id - if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] # Gateway sub devices @@ -256,7 +384,131 @@ async def async_setup_entry(hass, config_entry, async_add_entities): DOMAIN, plug_service, async_service_handler, schema=schema ) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) + + +class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): + """Representation of a Xiaomi Plug Generic.""" + + def __init__(self, name, device, entry, unique_id, switch, coordinator): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._attr_icon = switch.icon + self._controller = switch + self._attr_is_on = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + self._attr_is_on = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + self.async_write_ha_state() + + @property + def available(self): + """Return true when state is known.""" + if ( + super().available + and not self.coordinator.data.is_on + and not self._controller.available_with_device_off + ): + return False + return super().available + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + async def async_turn_on(self, **kwargs) -> None: + """Turn on an option of the miio device.""" + method = getattr(self, SERVICE_TO_METHOD[self._controller.service]["method_on"]) + if await method(): + # Write state back to avoid switch flips with a slow response + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off an option of the miio device.""" + method = getattr( + self, SERVICE_TO_METHOD[self._controller.service]["method_off"] + ) + if await method(): + # Write state back to avoid switch flips with a slow response + self._attr_is_on = False + self.async_write_ha_state() + + async def async_set_buzzer_on(self) -> bool: + """Turn the buzzer on.""" + return await self._try_command( + "Turning the buzzer of the miio device on failed.", + self._device.set_buzzer, + True, + ) + + async def async_set_buzzer_off(self) -> bool: + """Turn the buzzer off.""" + return await self._try_command( + "Turning the buzzer of the miio device off failed.", + self._device.set_buzzer, + False, + ) + + async def async_set_child_lock_on(self) -> bool: + """Turn the child lock on.""" + return await self._try_command( + "Turning the child lock of the miio device on failed.", + self._device.set_child_lock, + True, + ) + + async def async_set_child_lock_off(self) -> bool: + """Turn the child lock off.""" + return await self._try_command( + "Turning the child lock of the miio device off failed.", + self._device.set_child_lock, + False, + ) + + async def async_set_dry_on(self) -> bool: + """Turn the dry mode on.""" + return await self._try_command( + "Turning the dry mode of the miio device on failed.", + self._device.set_dry, + True, + ) + + async def async_set_dry_off(self) -> bool: + """Turn the dry mode off.""" + return await self._try_command( + "Turning the dry mode of the miio device off failed.", + self._device.set_dry, + False, + ) + + async def async_set_clean_on(self) -> bool: + """Turn the dry mode on.""" + return await self._try_command( + "Turning the clean mode of the miio device on failed.", + self._device.set_clean_mode, + True, + ) + + async def async_set_clean_off(self) -> bool: + """Turn the dry mode off.""" + return await self._try_command( + "Turning the clean mode of the miio device off failed.", + self._device.set_clean_mode, + False, + ) class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity):