From 5de1adacf79036938c90863e81e3fbb3363b55f9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 10 Aug 2021 14:55:11 +0200 Subject: [PATCH] Xiaomi miio add coordinator to fan platform (#54366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init coordinator for airpurifiers and airfresh * Update fan entities with coordinator * cache mode and fan_level at user update * pylint define attributes in _init * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Maciej Bieniek * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Maciej Bieniek * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Maciej Bieniek * cleanup code * Set hass.data[DATA_KEY] to enable * rename to filtered_entities in service handler * Update homeassistant/components/xiaomi_miio/fan.py Co-authored-by: Joakim Sørensen * flake Co-authored-by: Maciej Bieniek Co-authored-by: Joakim Sørensen --- .../components/xiaomi_miio/__init__.py | 58 +++- homeassistant/components/xiaomi_miio/fan.py | 316 +++++++----------- 2 files changed, 176 insertions(+), 198 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index bd9e69bd12d..faff2194948 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -3,7 +3,15 @@ from datetime import timedelta import logging import async_timeout -from miio import AirHumidifier, AirHumidifierMiot, AirHumidifierMjjsq, DeviceException +from miio import ( + AirFresh, + AirHumidifier, + AirHumidifierMiot, + AirHumidifierMjjsq, + AirPurifier, + AirPurifierMiot, + DeviceException, +) from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core @@ -23,10 +31,13 @@ from .const import ( KEY_DEVICE, MODELS_AIR_MONITOR, MODELS_FAN, + MODELS_FAN_MIIO, MODELS_HUMIDIFIER, + MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, MODELS_LIGHT, + MODELS_PURIFIER_MIOT, MODELS_SWITCH, MODELS_VACUUM, ) @@ -107,27 +118,52 @@ async def async_create_miio_device_and_coordinator( token = entry.data[CONF_TOKEN] name = entry.title device = None + migrate = False - if model not in MODELS_HUMIDIFIER: + if ( + model not in MODELS_HUMIDIFIER + and model not in MODELS_PURIFIER_MIOT + and model not in MODELS_FAN_MIIO + ): return _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + # Humidifiers if model in MODELS_HUMIDIFIER_MIOT: device = AirHumidifierMiot(host, token) + migrate = True elif model in MODELS_HUMIDIFIER_MJJSQ: device = AirHumidifierMjjsq(host, token, model=model) - else: + migrate = True + elif model in MODELS_HUMIDIFIER_MIIO: device = AirHumidifier(host, token, model=model) + migrate = True + # Airpurifiers and Airfresh + elif model in MODELS_PURIFIER_MIOT: + device = AirPurifierMiot(host, token) + elif model.startswith("zhimi.airpurifier."): + device = AirPurifier(host, token) + elif model.startswith("zhimi.airfresh."): + device = AirFresh(host, token) + else: + _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 - # Removing fan platform entity for humidifiers and migrate the name to the config entry 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 - if migrate_entity_name := entity_registry.async_get(entity_id).name: - hass.config_entries.async_update_entry(entry, title=migrate_entity_name) - entity_registry.async_remove(entity_id) + if migrate: + # Removing fan platform entity for humidifiers and migrate the name to the config entry 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 + if migrate_entity_name := entity_registry.async_get(entity_id).name: + hass.config_entries.async_update_entry(entry, title=migrate_entity_name) + entity_registry.async_remove(entity_id) async def async_update_data(): """Fetch data from the device using async_add_executor_job.""" diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index feeadf2bccc..fe4df2cd6d3 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,11 +1,9 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" import asyncio from enum import Enum -from functools import partial import logging import math -from miio import AirFresh, AirPurifier, AirPurifierMiot, DeviceException from miio.airfresh import ( LedBrightness as AirfreshLedBrightness, OperationMode as AirfreshOperationMode, @@ -35,6 +33,7 @@ from homeassistant.const import ( CONF_NAME, CONF_TOKEN, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -56,6 +55,8 @@ from .const import ( FEATURE_SET_LED, FEATURE_SET_LED_BRIGHTNESS, FEATURE_SET_VOLUME, + KEY_COORDINATOR, + KEY_DEVICE, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_PRO, @@ -79,9 +80,8 @@ from .const import ( SERVICE_SET_LEARN_MODE_ON, SERVICE_SET_LED_BRIGHTNESS, SERVICE_SET_VOLUME, - SUCCESS, ) -from .device import XiaomiMiioEntity +from .device import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) @@ -430,94 +430,89 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Fan from a config entry.""" entities = [] - if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: - if DATA_KEY not in hass.data: - hass.data[DATA_KEY] = {} + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return - 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 + hass.data.setdefault(DATA_KEY, {}) - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + name = config_entry.title + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] - if model in MODELS_PURIFIER_MIOT: - air_purifier = AirPurifierMiot(host, token) - entity = XiaomiAirPurifierMiot( - name, air_purifier, config_entry, unique_id, allowed_failures=2 - ) - elif model.startswith("zhimi.airpurifier."): - air_purifier = AirPurifier(host, token) - entity = XiaomiAirPurifier(name, air_purifier, config_entry, unique_id) - elif model.startswith("zhimi.airfresh."): - air_fresh = AirFresh(host, token) - entity = XiaomiAirFresh(name, air_fresh, config_entry, unique_id) + if model in MODELS_PURIFIER_MIOT: + entity = XiaomiAirPurifierMiot( + name, + device, + config_entry, + unique_id, + coordinator, + ) + elif model.startswith("zhimi.airpurifier."): + entity = XiaomiAirPurifier(name, device, config_entry, unique_id, coordinator) + elif model.startswith("zhimi.airfresh."): + entity = XiaomiAirFresh(name, device, config_entry, unique_id, coordinator) + else: + return + + hass.data[DATA_KEY][unique_id] = entity + + entities.append(entity) + + async def async_service_handler(service): + """Map services to methods on XiaomiAirPurifier.""" + method = SERVICE_TO_METHOD[service.service] + params = { + key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID + } + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + filtered_entities = [ + entity + for entity in hass.data[DATA_KEY].values() + if entity.entity_id in entity_ids + ] else: - _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 + filtered_entities = hass.data[DATA_KEY].values() - hass.data[DATA_KEY][host] = entity - entities.append(entity) + update_tasks = [] - async def async_service_handler(service): - """Map services to methods on XiaomiAirPurifier.""" - method = SERVICE_TO_METHOD[service.service] - params = { - key: value - for key, value in service.data.items() - if key != ATTR_ENTITY_ID - } - entity_ids = service.data.get(ATTR_ENTITY_ID) - if entity_ids: - entities = [ - entity - for entity in hass.data[DATA_KEY].values() - if entity.entity_id in entity_ids - ] - else: - entities = hass.data[DATA_KEY].values() - - update_tasks = [] - - for entity in entities: - entity_method = getattr(entity, method["method"], None) - if not entity_method: - continue - await entity_method(**params) - update_tasks.append( - hass.async_create_task(entity.async_update_ha_state(True)) - ) - - if update_tasks: - await asyncio.wait(update_tasks) - - for air_purifier_service, method in SERVICE_TO_METHOD.items(): - schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) - hass.services.async_register( - DOMAIN, air_purifier_service, async_service_handler, schema=schema + for entity in filtered_entities: + entity_method = getattr(entity, method["method"], None) + if not entity_method: + continue + await entity_method(**params) + update_tasks.append( + hass.async_create_task(entity.async_update_ha_state(True)) ) - async_add_entities(entities, update_before_add=True) + if update_tasks: + await asyncio.wait(update_tasks) + + for air_purifier_service, method in SERVICE_TO_METHOD.items(): + schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, air_purifier_service, async_service_handler, schema=schema + ) + + async_add_entities(entities) -class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): +class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Representation of a generic Xiaomi device.""" - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the generic Xiaomi device.""" - super().__init__(name, device, entry, unique_id) + super().__init__(name, device, entry, unique_id, coordinator) self._available = False + self._available_attributes = {} self._state = None + self._mode = None + self._fan_level = None self._state_attrs = {ATTR_MODEL: self._model} self._device_features = FEATURE_SET_CHILD_LOCK - self._skip_update = False self._supported_features = 0 self._speed_count = 100 self._preset_modes = [] @@ -583,22 +578,20 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): return value - 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 result == SUCCESS - except DeviceException as exc: - if self._available: - _LOGGER.error(mask_error, exc) - self._available = False - - return False + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._available = True + self._state = self.coordinator.data.is_on + self._state_attrs.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in self._available_attributes.items() + } + ) + self._mode = self._state_attrs.get(ATTR_MODE) + self._fan_level = self._state_attrs.get(ATTR_FAN_LEVEL) + self.async_write_ha_state() # # The fan entity model has changed to use percentages and preset_modes @@ -630,7 +623,7 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): if result: self._state = True - self._skip_update = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" @@ -640,7 +633,7 @@ class XiaomiGenericDevice(XiaomiMiioEntity, FanEntity): if result: self._state = False - self._skip_update = True + self.async_write_ha_state() async def async_set_buzzer_on(self): """Turn the buzzer on.""" @@ -706,11 +699,9 @@ class XiaomiAirPurifier(XiaomiGenericDevice): REVERSE_SPEED_MODE_MAPPING = {v: k for k, v in SPEED_MODE_MAPPING.items()} - def __init__(self, name, device, entry, unique_id, allowed_failures=0): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the plug switch.""" - super().__init__(name, device, entry, unique_id) - self._allowed_failures = allowed_failures - self._failure = 0 + super().__init__(name, device, entry, unique_id, coordinator) if self._model == MODEL_AIRPURIFIER_PRO: self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO @@ -774,45 +765,8 @@ class XiaomiAirPurifier(XiaomiGenericDevice): 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() - } - ) - - self._failure = 0 - - except DeviceException as ex: - self._failure += 1 - if self._failure < self._allowed_failures: - _LOGGER.info( - "Got exception while fetching the state: %s, failure: %d", - ex, - self._failure, - ) - else: - if self._available: - self._available = False - _LOGGER.error( - "Got exception while fetching the state: %s, failure: %d", - ex, - self._failure, - ) + self._mode = self._state_attrs.get(ATTR_MODE) + self._fan_level = self._state_attrs.get(ATTR_FAN_LEVEL) @property def preset_mode(self): @@ -1032,8 +986,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def percentage(self): """Return the current percentage based speed.""" if self._state: - fan_level = self._state_attrs[ATTR_FAN_LEVEL] - return ranged_value_to_percentage((1, 3), fan_level) + return ranged_value_to_percentage((1, 3), self._fan_level) return None @@ -1041,9 +994,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirpurifierMiotOperationMode( - self._state_attrs[ATTR_MODE] - ).name + preset_mode = AirpurifierMiotOperationMode(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -1053,7 +1004,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): def speed(self): """Return the current speed.""" if self._state: - return AirpurifierMiotOperationMode(self._state_attrs[ATTR_MODE]).name + return AirpurifierMiotOperationMode(self._mode).name return None @@ -1063,12 +1014,15 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): This method is a coroutine. """ fan_level = math.ceil(percentage_to_ranged_value((1, 3), percentage)) - if fan_level: - await self._try_command( - "Setting fan level of the miio device failed.", - self._device.set_fan_level, - fan_level, - ) + if not fan_level: + return + if await self._try_command( + "Setting fan level of the miio device failed.", + self._device.set_fan_level, + fan_level, + ): + self._fan_level = fan_level + self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. @@ -1078,11 +1032,13 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) + ): + self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self.async_write_ha_state() # the async_set_speed function is deprecated, support will end with release 2021.7 # it is added here only for compatibility with legacy speeds @@ -1093,11 +1049,13 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): _LOGGER.debug("Setting the operation mode to: %s", speed) - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirpurifierMiotOperationMode[speed.title()], - ) + ): + self._mode = AirpurifierMiotOperationMode[speed.title()].value + self.async_write_ha_state() async def async_set_led_brightness(self, brightness: int = 2): """Set the led brightness.""" @@ -1128,9 +1086,9 @@ class XiaomiAirFresh(XiaomiGenericDevice): "Interval": AirfreshOperationMode.Interval, } - def __init__(self, name, device, entry, unique_id): + def __init__(self, name, device, entry, unique_id, coordinator): """Initialize the miio device.""" - super().__init__(name, device, entry, unique_id) + super().__init__(name, device, entry, unique_id, coordinator) self._device_features = FEATURE_FLAGS_AIRFRESH self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH @@ -1142,37 +1100,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): 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) + self._mode = self._state_attrs.get(ATTR_MODE) @property def preset_mode(self): """Get the active preset mode.""" if self._state: - preset_mode = AirfreshOperationMode(self._state_attrs[ATTR_MODE]).name + preset_mode = AirfreshOperationMode(self._mode).name return preset_mode if preset_mode in self._preset_modes else None return None @@ -1181,7 +1115,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): def percentage(self): """Return the current percentage based speed.""" if self._state: - mode = AirfreshOperationMode(self._state_attrs[ATTR_MODE]) + mode = AirfreshOperationMode(self._mode) if mode in self.REVERSE_SPEED_MODE_MAPPING: return ranged_value_to_percentage( (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] @@ -1194,7 +1128,7 @@ class XiaomiAirFresh(XiaomiGenericDevice): def speed(self): """Return the current speed.""" if self._state: - return AirfreshOperationMode(self._state_attrs[ATTR_MODE]).name + return AirfreshOperationMode(self._mode).name return None @@ -1207,11 +1141,15 @@ class XiaomiAirFresh(XiaomiGenericDevice): percentage_to_ranged_value((1, self._speed_count), percentage) ) if speed_mode: - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirfreshOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), - ) + ): + self._mode = AirfreshOperationMode( + self.SPEED_MODE_MAPPING[speed_mode] + ).value + self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. @@ -1221,11 +1159,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): if preset_mode not in self.preset_modes: _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) return - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, self.PRESET_MODE_MAPPING[preset_mode], - ) + ): + self._mode = self.PRESET_MODE_MAPPING[preset_mode].value + self.async_write_ha_state() # the async_set_speed function is deprecated, support will end with release 2021.7 # it is added here only for compatibility with legacy speeds @@ -1236,11 +1176,13 @@ class XiaomiAirFresh(XiaomiGenericDevice): _LOGGER.debug("Setting the operation mode to: %s", speed) - await self._try_command( + if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, AirfreshOperationMode[speed.title()], - ) + ): + self._mode = AirfreshOperationMode[speed.title()].value + self.async_write_ha_state() async def async_set_led_on(self): """Turn the led on."""