From 65f86ce44fa663c64cbd6f16331fc6e1e9806506 Mon Sep 17 00:00:00 2001 From: Igor Pakhomov Date: Tue, 16 Aug 2022 18:30:56 +0300 Subject: [PATCH] Add additional select for dmaker.airfresh.t2017 to xiaomi_miio (#67058) --- .coveragerc | 1 - .../components/xiaomi_miio/select.py | 320 +++++++++--------- .../xiaomi_miio/strings.select.json | 10 + tests/components/xiaomi_miio/test_select.py | 158 +++++++++ 4 files changed, 329 insertions(+), 160 deletions(-) create mode 100644 tests/components/xiaomi_miio/test_select.py diff --git a/.coveragerc b/.coveragerc index 8b632a524ff..d3e1b75b928 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1478,7 +1478,6 @@ omit = 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_tv/media_player.py diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 7f6f8ce1cb1..14ceac7f2f4 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -1,9 +1,14 @@ """Support led_brightness for Mi Air Humidifier.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import NamedTuple from miio.fan_common import LedBrightness as FanLedBrightness +from miio.integrations.airpurifier.dmaker.airfresh_t2017 import ( + DisplayOrientation as AirfreshT2017DisplayOrientation, + PtcLevel as AirfreshT2017PtcLevel, +) from miio.integrations.airpurifier.zhimi.airfresh import ( LedBrightness as AirfreshLedBrightness, ) @@ -31,53 +36,134 @@ from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, - FEATURE_SET_LED_BRIGHTNESS, KEY_COORDINATOR, KEY_DEVICE, + MODEL_AIRFRESH_T2017, MODEL_AIRFRESH_VA2, - MODEL_AIRPURIFIER_3C, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODEL_AIRHUMIDIFIER_V1, + MODEL_AIRPURIFIER_3, + MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2, + MODEL_AIRPURIFIER_PROH, MODEL_FAN_SA1, MODEL_FAN_V2, MODEL_FAN_V3, MODEL_FAN_ZA1, MODEL_FAN_ZA3, MODEL_FAN_ZA4, - MODELS_HUMIDIFIER_MIIO, - MODELS_HUMIDIFIER_MIOT, - MODELS_PURIFIER_MIOT, ) from .device import XiaomiCoordinatedMiioEntity +ATTR_DISPLAY_ORIENTATION = "display_orientation" ATTR_LED_BRIGHTNESS = "led_brightness" - - -LED_BRIGHTNESS_MAP = {"Bright": 0, "Dim": 1, "Off": 2} -LED_BRIGHTNESS_MAP_HUMIDIFIER_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_HUMIDIFIER_MIOT = { - val: key for key, val in LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT.items() -} +ATTR_PTC_LEVEL = "ptc_level" @dataclass class XiaomiMiioSelectDescription(SelectEntityDescription): """A class that describes select entities.""" + attr_name: str = "" + options_map: dict = field(default_factory=dict) + set_method: str = "" + set_method_error_message: str = "" options: tuple = () -SELECTOR_TYPES = { - FEATURE_SET_LED_BRIGHTNESS: XiaomiMiioSelectDescription( +class AttributeEnumMapping(NamedTuple): + """Class to mapping Attribute to Enum Class.""" + + attr_name: str + enum_class: type + + +MODEL_TO_ATTR_MAP: dict[str, list] = { + MODEL_AIRFRESH_T2017: [ + AttributeEnumMapping(ATTR_DISPLAY_ORIENTATION, AirfreshT2017DisplayOrientation), + AttributeEnumMapping(ATTR_PTC_LEVEL, AirfreshT2017PtcLevel), + ], + MODEL_AIRFRESH_VA2: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirfreshLedBrightness) + ], + MODEL_AIRHUMIDIFIER_CA1: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirhumidifierLedBrightness) + ], + MODEL_AIRHUMIDIFIER_CA4: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirhumidifierMiotLedBrightness) + ], + MODEL_AIRHUMIDIFIER_CB1: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirhumidifierLedBrightness) + ], + MODEL_AIRHUMIDIFIER_V1: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirhumidifierLedBrightness) + ], + MODEL_AIRPURIFIER_3: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], + MODEL_AIRPURIFIER_3H: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], + MODEL_AIRPURIFIER_M1: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierLedBrightness) + ], + MODEL_AIRPURIFIER_M2: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierLedBrightness) + ], + MODEL_AIRPURIFIER_PROH: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], + MODEL_FAN_SA1: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], + MODEL_FAN_V2: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], + MODEL_FAN_V3: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], + MODEL_FAN_ZA1: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], + MODEL_FAN_ZA3: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], + MODEL_FAN_ZA4: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], +} + +SELECTOR_TYPES = ( + XiaomiMiioSelectDescription( + key=ATTR_DISPLAY_ORIENTATION, + attr_name=ATTR_DISPLAY_ORIENTATION, + name="Display Orientation", + options_map={ + "Portrait": "Forward", + "LandscapeLeft": "Left", + "LandscapeRight": "Right", + }, + set_method="set_display_orientation", + set_method_error_message="Setting the display orientation failed.", + icon="mdi:tablet", + device_class="xiaomi_miio__display_orientation", + options=("forward", "left", "right"), + entity_category=EntityCategory.CONFIG, + ), + XiaomiMiioSelectDescription( key=ATTR_LED_BRIGHTNESS, + attr_name=ATTR_LED_BRIGHTNESS, name="Led Brightness", + set_method="set_led_brightness", + set_method_error_message="Setting the led brightness failed.", icon="mdi:brightness-6", device_class="xiaomi_miio__led_brightness", options=("bright", "dim", "off"), entity_category=EntityCategory.CONFIG, ), -} + XiaomiMiioSelectDescription( + key=ATTR_PTC_LEVEL, + attr_name=ATTR_PTC_LEVEL, + name="Auxiliary Heat Level", + set_method="set_ptc_level", + set_method_error_message="Setting the ptc level failed.", + icon="mdi:fire-circle", + device_class="xiaomi_miio__ptc_level", + options=("low", "medium", "high"), + entity_category=EntityCategory.CONFIG, + ), +) async def async_setup_entry( @@ -89,44 +175,29 @@ async def async_setup_entry( if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: return - entities = [] model = config_entry.data[CONF_MODEL] + if model not in MODEL_TO_ATTR_MAP: + return + + entities = [] + 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] + attributes = MODEL_TO_ATTR_MAP[model] - if model == MODEL_AIRPURIFIER_3C: - return - if model in MODELS_HUMIDIFIER_MIIO: - entity_class = XiaomiAirHumidifierSelector - elif model in MODELS_HUMIDIFIER_MIOT: - entity_class = XiaomiAirHumidifierMiotSelector - elif model in [MODEL_AIRPURIFIER_M1, MODEL_AIRPURIFIER_M2]: - entity_class = XiaomiAirPurifierSelector - elif model in MODELS_PURIFIER_MIOT: - entity_class = XiaomiAirPurifierMiotSelector - elif model == MODEL_AIRFRESH_VA2: - entity_class = XiaomiAirFreshSelector - elif model in ( - MODEL_FAN_ZA1, - MODEL_FAN_ZA3, - MODEL_FAN_ZA4, - MODEL_FAN_SA1, - MODEL_FAN_V2, - MODEL_FAN_V3, - ): - entity_class = XiaomiFanSelector - else: - return - - description = SELECTOR_TYPES[FEATURE_SET_LED_BRIGHTNESS] - entities.append( - entity_class( - device, - config_entry, - f"{description.key}_{config_entry.unique_id}", - hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], - description, - ) - ) + for description in SELECTOR_TYPES: + for attribute in attributes: + if description.key == attribute.attr_name: + entities.append( + XiaomiGenericSelector( + device, + config_entry, + f"{description.key}_{unique_id}", + coordinator, + description, + attribute.enum_class, + ) + ) async_add_entities(entities) @@ -141,129 +212,60 @@ class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): self.entity_description = description -class XiaomiAirHumidifierSelector(XiaomiSelector): - """Representation of a Xiaomi Air Humidifier selector.""" +class XiaomiGenericSelector(XiaomiSelector): + """Representation of a Xiaomi generic selector.""" - def __init__(self, device, entry, unique_id, coordinator, description): - """Initialize the plug switch.""" + entity_description: XiaomiMiioSelectDescription + + def __init__(self, device, entry, unique_id, coordinator, description, enum_class): + """Initialize the generic Xiaomi attribute selector.""" super().__init__(device, entry, unique_id, coordinator, description) - self._current_led_brightness = self._extract_value_from_attribute( - self.coordinator.data, self.entity_description.key + self._current_attr = enum_class( + self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.attr_name + ) ) + if description.options_map: + self._options_map = {} + for key, val in enum_class._member_map_.items(): + self._options_map[description.options_map[key]] = val + else: + self._options_map = enum_class._member_map_ + self._reverse_map = {val: key for key, val in self._options_map.items()} + self._enum_class = enum_class + @callback def _handle_coordinator_update(self): """Fetch state from the device.""" - led_brightness = self._extract_value_from_attribute( - self.coordinator.data, self.entity_description.key + attr = self._enum_class( + self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.attr_name + ) ) - # Sometimes (quite rarely) the device returns None as the LED brightness so we - # check that the value is not None before updating the state. - if led_brightness is not None: - self._current_led_brightness = led_brightness + if attr is not None: + self._current_attr = attr self.async_write_ha_state() @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the current option.""" - return self.led_brightness.lower() + option = self._reverse_map.get(self._current_attr) + if option is not None: + return option.lower() + return None async def async_select_option(self, option: str) -> None: """Set an option of the miio device.""" - await self.async_set_led_brightness(option.title()) + await self.async_set_attr(option.title()) - @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.""" + async def async_set_attr(self, attr_value: str): + """Set attr.""" + method = getattr(self._device, self.entity_description.set_method) 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.entity_description.set_method_error_message, + method, + self._enum_class(self._options_map[attr_value]), ): - 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_HUMIDIFIER_MIOT.get( - self._current_led_brightness - ) - - async def async_set_led_brightness(self, brightness: str) -> None: - """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_HUMIDIFIER_MIOT[brightness] - ), - ): - self._current_led_brightness = LED_BRIGHTNESS_MAP_HUMIDIFIER_MIOT[ - brightness - ] - self.async_write_ha_state() - - -class XiaomiAirPurifierSelector(XiaomiAirHumidifierSelector): - """Representation of a Xiaomi Air Purifier (MIIO protocol) selector.""" - - async def async_set_led_brightness(self, brightness: str) -> None: - """Set the led brightness.""" - if await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirpurifierLedBrightness(LED_BRIGHTNESS_MAP[brightness]), - ): - self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] - self.async_write_ha_state() - - -class XiaomiAirPurifierMiotSelector(XiaomiAirHumidifierSelector): - """Representation of a Xiaomi Air Purifier (MiOT protocol) selector.""" - - async def async_set_led_brightness(self, brightness: str) -> None: - """Set the led brightness.""" - if await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirpurifierMiotLedBrightness(LED_BRIGHTNESS_MAP[brightness]), - ): - self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] - self.async_write_ha_state() - - -class XiaomiFanSelector(XiaomiAirHumidifierSelector): - """Representation of a Xiaomi Fan (MIIO protocol) selector.""" - - async def async_set_led_brightness(self, brightness: str) -> None: - """Set the led brightness.""" - if await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - FanLedBrightness(LED_BRIGHTNESS_MAP[brightness]), - ): - self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] - self.async_write_ha_state() - - -class XiaomiAirFreshSelector(XiaomiAirHumidifierSelector): - """Representation of a Xiaomi Air Fresh selector.""" - - async def async_set_led_brightness(self, brightness: str) -> None: - """Set the led brightness.""" - if await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirfreshLedBrightness(LED_BRIGHTNESS_MAP[brightness]), - ): - self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self._current_attr = self._options_map[attr_value] self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/strings.select.json b/homeassistant/components/xiaomi_miio/strings.select.json index 265aec66531..38ee8b1aa07 100644 --- a/homeassistant/components/xiaomi_miio/strings.select.json +++ b/homeassistant/components/xiaomi_miio/strings.select.json @@ -4,6 +4,16 @@ "bright": "Bright", "dim": "Dim", "off": "Off" + }, + "xiaomi_miio__display_orientation": { + "forward": "Forward", + "left": "Left", + "right": "Right" + }, + "xiaomi_miio__ptc_level": { + "low": "Low", + "medium": "Medium", + "high": "High" } } } diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py new file mode 100644 index 00000000000..3fa8a3de291 --- /dev/null +++ b/tests/components/xiaomi_miio/test_select.py @@ -0,0 +1,158 @@ +"""The tests for the xiaomi_miio select component.""" + +from unittest.mock import MagicMock, patch + +from arrow import utcnow +from miio.integrations.airpurifier.dmaker.airfresh_t2017 import ( + DisplayOrientation, + PtcLevel, +) +import pytest + +from homeassistant.components.select import DOMAIN +from homeassistant.components.select.const import ( + ATTR_OPTION, + ATTR_OPTIONS, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.xiaomi_miio import UPDATE_INTERVAL +from homeassistant.components.xiaomi_miio.const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MAC, + DOMAIN as XIAOMI_DOMAIN, + MODEL_AIRFRESH_T2017, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_MODEL, + CONF_TOKEN, + Platform, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.xiaomi_miio import TEST_MAC + + +@pytest.fixture(autouse=True) +async def setup_test(hass: HomeAssistant): + """Initialize test xiaomi_miio for select entity.""" + + mock_airfresh = MagicMock() + mock_airfresh.status().display_orientation = DisplayOrientation.Portrait + mock_airfresh.status().ptc_level = PtcLevel.Low + + with patch( + "homeassistant.components.xiaomi_miio.get_platforms", + return_value=[ + Platform.SELECT, + ], + ), patch("homeassistant.components.xiaomi_miio.AirFreshT2017") as mock_airfresh_cls: + mock_airfresh_cls.return_value = mock_airfresh + yield mock_airfresh + + +async def test_select_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + + entity_name = "test_airfresh_select" + entity_id = await setup_component(hass, entity_name) + + select_entity = hass.states.get(entity_id + "_display_orientation") + assert select_entity + assert select_entity.state == "forward" + assert select_entity.attributes.get(ATTR_OPTIONS) == ["forward", "left", "right"] + + +async def test_select_bad_attr(hass: HomeAssistant) -> None: + """Test selecting a different option with invalid option value.""" + + entity_name = "test_airfresh_select" + entity_id = await setup_component(hass, entity_name) + + state = hass.states.get(entity_id + "_display_orientation") + assert state + assert state.state == "forward" + + with pytest.raises(ValueError): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "up", ATTR_ENTITY_ID: entity_id + "_display_orientation"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id + "_display_orientation") + assert state + assert state.state == "forward" + + +async def test_select_option(hass: HomeAssistant) -> None: + """Test selecting of a option.""" + + entity_name = "test_airfresh_select" + entity_id = await setup_component(hass, entity_name) + + state = hass.states.get(entity_id + "_display_orientation") + assert state + assert state.state == "forward" + + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "left", ATTR_ENTITY_ID: entity_id + "_display_orientation"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id + "_display_orientation") + assert state + assert state.state == "left" + + +async def test_select_coordinator_update(hass: HomeAssistant, setup_test) -> None: + """Test coordinator update of a option.""" + + entity_name = "test_airfresh_select" + entity_id = await setup_component(hass, entity_name) + + state = hass.states.get(entity_id + "_display_orientation") + assert state + assert state.state == "forward" + + # emulate someone change state from device maybe used app + setup_test.status().display_orientation = DisplayOrientation.LandscapeLeft + + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(entity_id + "_display_orientation") + assert state + assert state.state == "left" + + +async def setup_component(hass, entity_name): + """Set up component.""" + entity_id = f"{DOMAIN}.{entity_name}" + + config_entry = MockConfigEntry( + domain=XIAOMI_DOMAIN, + unique_id="123456", + title=entity_name, + data={ + CONF_FLOW_TYPE: CONF_DEVICE, + CONF_HOST: "0.0.0.0", + CONF_TOKEN: "12345678901234567890123456789012", + CONF_MODEL: MODEL_AIRFRESH_T2017, + CONF_MAC: TEST_MAC, + }, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return entity_id