From 5b17aaf9d5a3093218727190b92bce1e73e4688e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 26 Mar 2021 08:37:47 +0100 Subject: [PATCH] Percentage and preset mode support for MQTT fan (#47944) * git push --all origin * Fix percentage to ordered list conversion * Tests for mqtt fan and fixes * Improve tests and error handling base config * Additional tests * Tests completed, small fixes * Allow preset mode and percentages combined * Remove raise in setup and update tests * Alignment with fan entity mode * Fix pylint for len-as-condition * Remove python binary cache file from PR * Additional tests on async_turn_on and fix * Added comments for deprecation of speeds * Schema checks before init * Optimize pre schema checks * Correct schema checks * Update homeassistant/components/mqtt/abbreviations.py Comment speeds for mqtt fan are deprecated not needed here Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Comment speeds for mqtt fan are deprecated not needed here Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Comment speeds for mqtt fan are deprecated not needed here Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Comment speeds for mqtt fan are deprecated not needed here Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Comment speeds for mqtt fan are deprecated not needed here Co-authored-by: Erik Montnemery * Warnings for exceptions - testing speed_range * Update homeassistant/components/mqtt/abbreviations.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/fan.py * Save with black Co-authored-by: Erik Montnemery --- .../components/mqtt/abbreviations.py | 9 + homeassistant/components/mqtt/fan.py | 462 +++++- tests/components/mqtt/test_fan.py | 1411 +++++++++++++++-- 3 files changed, 1725 insertions(+), 157 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 868e2fdd791..a65c78f87d1 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -88,6 +88,9 @@ ABBREVIATIONS = { "osc_cmd_t": "oscillation_command_topic", "osc_stat_t": "oscillation_state_topic", "osc_val_tpl": "oscillation_value_template", + "pct_cmd_t": "percentage_command_topic", + "pct_stat_t": "percentage_state_topic", + "pct_val_tpl": "percentage_value_template", "pl": "payload", "pl_arm_away": "payload_arm_away", "pl_arm_home": "payload_arm_home", @@ -124,6 +127,10 @@ ABBREVIATIONS = { "pow_cmd_t": "power_command_topic", "pow_stat_t": "power_state_topic", "pow_stat_tpl": "power_state_template", + "pr_mode_cmd_t": "preset_mode_command_topic", + "pr_mode_stat_t": "preset_mode_state_topic", + "pr_mode_val_tpl": "preset_mode_value_template", + "pr_modes": "preset_modes", "r_tpl": "red_template", "ret": "retain", "rgb_cmd_tpl": "rgb_command_template", @@ -139,6 +146,8 @@ ABBREVIATIONS = { "pos_tpl": "position_template", "spd_cmd_t": "speed_command_topic", "spd_stat_t": "speed_state_topic", + "spd_rng_min": "speed_range_min", + "spd_rng_max": "speed_range_max", "spd_val_tpl": "speed_value_template", "spds": "speeds", "src_type": "source_type", diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index c0663370805..a0395039e78 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -1,18 +1,23 @@ """Support for MQTT fans.""" import functools +import logging import voluptuous as vol from homeassistant.components import fan from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_OSCILLATE, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, + speed_list_without_preset_modes, ) from homeassistant.const import ( CONF_NAME, @@ -25,6 +30,12 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import ( CONF_COMMAND_TOPIC, @@ -40,6 +51,15 @@ from .debug_info import log_messages from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper CONF_STATE_VALUE_TEMPLATE = "state_value_template" +CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" +CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" +CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template" +CONF_SPEED_RANGE_MIN = "speed_range_min" +CONF_SPEED_RANGE_MAX = "speed_range_max" +CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" +CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" +CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" +CONF_PRESET_MODES_LIST = "preset_modes" CONF_SPEED_STATE_TOPIC = "speed_state_topic" CONF_SPEED_COMMAND_TOPIC = "speed_command_topic" CONF_SPEED_VALUE_TEMPLATE = "speed_value_template" @@ -58,41 +78,96 @@ DEFAULT_NAME = "MQTT Fan" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_PAYLOAD_OFF = "OFF" DEFAULT_OPTIMISTIC = False +DEFAULT_SPEED_RANGE_MIN = 1 +DEFAULT_SPEED_RANGE_MAX = 100 OSCILLATE_ON_PAYLOAD = "oscillate_on" OSCILLATE_OFF_PAYLOAD = "oscillate_off" OSCILLATION = "oscillation" -PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string, - vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string, - vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MEDIUM): cv.string, - vol.Optional(CONF_PAYLOAD_OFF_SPEED, default=SPEED_OFF): cv.string, - vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, - vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, - vol.Optional( - CONF_PAYLOAD_OSCILLATION_OFF, default=OSCILLATE_OFF_PAYLOAD - ): cv.string, - vol.Optional( - CONF_PAYLOAD_OSCILLATION_ON, default=OSCILLATE_ON_PAYLOAD - ): cv.string, - vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional( - CONF_SPEED_LIST, - default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], - ): cv.ensure_list, - vol.Optional(CONF_SPEED_STATE_TOPIC): mqtt.valid_subscribe_topic, - vol.Optional(CONF_SPEED_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, - } -).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +_LOGGER = logging.getLogger(__name__) + + +def valid_fan_speed_configuration(config): + """Validate that the fan speed configuration is valid, throws if it isn't.""" + if config.get(CONF_SPEED_COMMAND_TOPIC) and not speed_list_without_preset_modes( + config.get(CONF_SPEED_LIST) + ): + raise ValueError("No valid speeds configured") + return config + + +def valid_speed_range_configuration(config): + """Validate that the fan speed_range configuration is valid, throws if it isn't.""" + if config.get(CONF_SPEED_RANGE_MIN) == 0: + raise ValueError("speed_range_min must be > 0") + if config.get(CONF_SPEED_RANGE_MIN) >= config.get(CONF_SPEED_RANGE_MAX): + raise ValueError("speed_range_max must be > speed_range_min") + return config + + +PLATFORM_SCHEMA = vol.All( + # CONF_SPEED_COMMAND_TOPIC, CONF_SPEED_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, CONF_SPEED_LIST and + # Speeds SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH SPEED_OFF, + # are deprecated, support will be removed after a quarter (2021.7) + cv.deprecated(CONF_PAYLOAD_HIGH_SPEED), + cv.deprecated(CONF_PAYLOAD_LOW_SPEED), + cv.deprecated(CONF_PAYLOAD_MEDIUM_SPEED), + cv.deprecated(CONF_SPEED_LIST), + cv.deprecated(CONF_SPEED_COMMAND_TOPIC), + cv.deprecated(CONF_SPEED_STATE_TOPIC), + cv.deprecated(CONF_SPEED_VALUE_TEMPLATE), + mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_PERCENTAGE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_PERCENTAGE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PERCENTAGE_VALUE_TEMPLATE): cv.template, + # CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together + vol.Inclusive( + CONF_PRESET_MODE_COMMAND_TOPIC, "preset_modes" + ): mqtt.valid_publish_topic, + vol.Inclusive( + CONF_PRESET_MODES_LIST, "preset_modes", default=[] + ): cv.ensure_list, + vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, + vol.Optional( + CONF_SPEED_RANGE_MIN, default=DEFAULT_SPEED_RANGE_MIN + ): cv.positive_int, + vol.Optional( + CONF_SPEED_RANGE_MAX, default=DEFAULT_SPEED_RANGE_MAX + ): cv.positive_int, + vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string, + vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string, + vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MEDIUM): cv.string, + vol.Optional(CONF_PAYLOAD_OFF_SPEED, default=SPEED_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional( + CONF_PAYLOAD_OSCILLATION_OFF, default=OSCILLATE_OFF_PAYLOAD + ): cv.string, + vol.Optional( + CONF_PAYLOAD_OSCILLATION_ON, default=OSCILLATE_ON_PAYLOAD + ): cv.string, + vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional( + CONF_SPEED_LIST, + default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], + ): cv.ensure_list, + vol.Optional(CONF_SPEED_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SPEED_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + valid_fan_speed_configuration, + valid_speed_range_configuration, +) async def async_setup_platform( @@ -124,7 +199,10 @@ class MqttFan(MqttEntity, FanEntity): def __init__(self, hass, config, config_entry, discovery_data): """Initialize the MQTT fan.""" self._state = False + # self._speed will be removed after a quarter (2021.7) self._speed = None + self._percentage = None + self._preset_mode = None self._oscillation = None self._supported_features = 0 @@ -133,6 +211,8 @@ class MqttFan(MqttEntity, FanEntity): self._templates = None self._optimistic = None self._optimistic_oscillation = None + self._optimistic_percentage = None + self._optimistic_preset_mode = None self._optimistic_speed = None MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -144,11 +224,19 @@ class MqttFan(MqttEntity, FanEntity): def _setup_from_config(self, config): """(Re)Setup the entity.""" + self._speed_range = ( + config.get(CONF_SPEED_RANGE_MIN), + config.get(CONF_SPEED_RANGE_MAX), + ) self._topic = { key: config.get(key) for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_PERCENTAGE_STATE_TOPIC, + CONF_PERCENTAGE_COMMAND_TOPIC, + CONF_PRESET_MODE_STATE_TOPIC, + CONF_PRESET_MODE_COMMAND_TOPIC, CONF_SPEED_STATE_TOPIC, CONF_SPEED_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, @@ -157,6 +245,9 @@ class MqttFan(MqttEntity, FanEntity): } self._templates = { CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE), + ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE), + # ATTR_SPEED is deprecated in the schema, support will be removed after a quarter (2021.7) ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE), } @@ -165,16 +256,53 @@ class MqttFan(MqttEntity, FanEntity): "STATE_OFF": config[CONF_PAYLOAD_OFF], "OSCILLATE_ON_PAYLOAD": config[CONF_PAYLOAD_OSCILLATION_ON], "OSCILLATE_OFF_PAYLOAD": config[CONF_PAYLOAD_OSCILLATION_OFF], + # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) "SPEED_LOW": config[CONF_PAYLOAD_LOW_SPEED], "SPEED_MEDIUM": config[CONF_PAYLOAD_MEDIUM_SPEED], "SPEED_HIGH": config[CONF_PAYLOAD_HIGH_SPEED], "SPEED_OFF": config[CONF_PAYLOAD_OFF_SPEED], } + # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) + self._feature_legacy_speeds = not self._topic[CONF_SPEED_COMMAND_TOPIC] is None + if self._feature_legacy_speeds: + self._legacy_speeds_list = config[CONF_SPEED_LIST] + self._legacy_speeds_list_no_off = speed_list_without_preset_modes( + self._legacy_speeds_list + ) + else: + self._legacy_speeds_list = [] + + self._feature_percentage = CONF_PERCENTAGE_COMMAND_TOPIC in config + self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config + if self._feature_preset_mode: + self._speeds_list = speed_list_without_preset_modes( + self._legacy_speeds_list + config[CONF_PRESET_MODES_LIST] + ) + self._preset_modes = ( + self._legacy_speeds_list + config[CONF_PRESET_MODES_LIST] + ) + else: + self._speeds_list = speed_list_without_preset_modes( + self._legacy_speeds_list + ) + self._preset_modes = [] + + if not self._speeds_list or self._feature_percentage: + self._speed_count = 100 + else: + self._speed_count = len(self._speeds_list) + optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None self._optimistic_oscillation = ( optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None ) + self._optimistic_percentage = ( + optimistic or self._topic[CONF_PERCENTAGE_STATE_TOPIC] is None + ) + self._optimistic_preset_mode = ( + optimistic or self._topic[CONF_PRESET_MODE_STATE_TOPIC] is None + ) self._optimistic_speed = ( optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None ) @@ -184,9 +312,14 @@ class MqttFan(MqttEntity, FanEntity): self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None and SUPPORT_OSCILLATE ) - self._supported_features |= ( - self._topic[CONF_SPEED_COMMAND_TOPIC] is not None and SUPPORT_SET_SPEED - ) + if self._feature_preset_mode and self._speeds_list: + self._supported_features |= SUPPORT_SET_SPEED + if self._feature_percentage: + self._supported_features |= SUPPORT_SET_SPEED + if self._feature_legacy_speeds: + self._supported_features |= SUPPORT_SET_SPEED + if self._feature_preset_mode: + self._supported_features |= SUPPORT_PRESET_MODE for key, tpl in list(self._templates.items()): if tpl is None: @@ -217,19 +350,103 @@ class MqttFan(MqttEntity, FanEntity): "qos": self._config[CONF_QOS], } + @callback + @log_messages(self.hass, self.entity_id) + def percentage_received(msg): + """Handle new received MQTT message for the percentage.""" + numeric_val_str = self._templates[ATTR_PERCENTAGE](msg.payload) + try: + percentage = ranged_value_to_percentage( + self._speed_range, int(numeric_val_str) + ) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s is not a valid speed within the speed range", + msg.payload, + msg.topic, + ) + return + if percentage < 0 or percentage > 100: + _LOGGER.warning( + "'%s' received on topic %s is not a valid speed within the speed range", + msg.payload, + msg.topic, + ) + return + self._percentage = percentage + self.async_write_ha_state() + + if self._topic[CONF_PERCENTAGE_STATE_TOPIC] is not None: + topics[CONF_PERCENTAGE_STATE_TOPIC] = { + "topic": self._topic[CONF_PERCENTAGE_STATE_TOPIC], + "msg_callback": percentage_received, + "qos": self._config[CONF_QOS], + } + self._percentage = None + + @callback + @log_messages(self.hass, self.entity_id) + def preset_mode_received(msg): + """Handle new received MQTT message for preset mode.""" + preset_mode = self._templates[ATTR_PRESET_MODE](msg.payload) + if preset_mode not in self.preset_modes: + _LOGGER.warning( + "'%s' received on topic %s is not a valid preset mode", + msg.payload, + msg.topic, + ) + return + + self._preset_mode = preset_mode + if not self._implemented_percentage and (preset_mode in self.speed_list): + self._percentage = ordered_list_item_to_percentage( + self.speed_list, preset_mode + ) + self.async_write_ha_state() + + if self._topic[CONF_PRESET_MODE_STATE_TOPIC] is not None: + topics[CONF_PRESET_MODE_STATE_TOPIC] = { + "topic": self._topic[CONF_PRESET_MODE_STATE_TOPIC], + "msg_callback": preset_mode_received, + "qos": self._config[CONF_QOS], + } + self._preset_mode = None + + # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) @callback @log_messages(self.hass, self.entity_id) def speed_received(msg): """Handle new received MQTT message for the speed.""" - payload = self._templates[ATTR_SPEED](msg.payload) - if payload == self._payload["SPEED_LOW"]: - self._speed = SPEED_LOW - elif payload == self._payload["SPEED_MEDIUM"]: - self._speed = SPEED_MEDIUM - elif payload == self._payload["SPEED_HIGH"]: - self._speed = SPEED_HIGH - elif payload == self._payload["SPEED_OFF"]: - self._speed = SPEED_OFF + speed_payload = self._templates[ATTR_SPEED](msg.payload) + if speed_payload == self._payload["SPEED_LOW"]: + speed = SPEED_LOW + elif speed_payload == self._payload["SPEED_MEDIUM"]: + speed = SPEED_MEDIUM + elif speed_payload == self._payload["SPEED_HIGH"]: + speed = SPEED_HIGH + elif speed_payload == self._payload["SPEED_OFF"]: + speed = SPEED_OFF + else: + speed = None + + if speed and speed in self._legacy_speeds_list: + self._speed = speed + else: + _LOGGER.warning( + "'%s' received on topic %s is not a valid speed", + msg.payload, + msg.topic, + ) + return + + if not self._implemented_percentage: + if speed in self._speeds_list: + self._percentage = ordered_list_item_to_percentage( + self._speeds_list, speed + ) + elif speed == SPEED_OFF: + self._percentage = 0 + self.async_write_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: @@ -273,10 +490,42 @@ class MqttFan(MqttEntity, FanEntity): """Return true if device is on.""" return self._state + @property + def _implemented_percentage(self): + """Return true if percentage has been implemented.""" + return self._feature_percentage + + @property + def _implemented_preset_mode(self): + """Return true if preset_mode has been implemented.""" + return self._feature_preset_mode + + # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) + @property + def _implemented_speed(self): + """Return true if speed has been implemented.""" + return self._feature_legacy_speeds + + @property + def percentage(self): + """Return the current percentage.""" + return self._percentage + + @property + def preset_mode(self): + """Return the current preset _mode.""" + return self._preset_mode + + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + return self._preset_modes + + # The speed_list property is deprecated in the schema, support will be removed after a quarter (2021.7) @property def speed_list(self) -> list: """Get the list of available speeds.""" - return self._config[CONF_SPEED_LIST] + return self._speeds_list @property def supported_features(self) -> int: @@ -288,18 +537,17 @@ class MqttFan(MqttEntity, FanEntity): """Return the current speed.""" return self._speed + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports or 100 if percentage is supported.""" + return self._speed_count + @property def oscillating(self): """Return the oscillation state.""" return self._oscillation - # - # The fan entity model has changed to use percentages and preset_modes - # instead of speeds. - # - # Please review - # https://developers.home-assistant.io/docs/core/entity/fan/ - # + # The speed attribute deprecated in the schema, support will be removed after a quarter (2021.7) async def async_turn_on( self, speed: str = None, @@ -318,7 +566,12 @@ class MqttFan(MqttEntity, FanEntity): self._config[CONF_QOS], self._config[CONF_RETAIN], ) - if speed: + if percentage: + await self.async_set_percentage(percentage) + if preset_mode: + await self.async_set_preset_mode(preset_mode) + # The speed attribute deprecated in the schema, support will be removed after a quarter (2021.7) + if speed and not percentage and not preset_mode: await self.async_set_speed(speed) if self._optimistic: self._state = True @@ -340,32 +593,111 @@ class MqttFan(MqttEntity, FanEntity): self._state = False self.async_write_ha_state() - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan. + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage of the fan. This method is a coroutine. """ - if speed == SPEED_LOW: - mqtt_payload = self._payload["SPEED_LOW"] - elif speed == SPEED_MEDIUM: - mqtt_payload = self._payload["SPEED_MEDIUM"] - elif speed == SPEED_HIGH: - mqtt_payload = self._payload["SPEED_HIGH"] - elif speed == SPEED_OFF: - mqtt_payload = self._payload["SPEED_OFF"] - else: - raise ValueError(f"{speed} is not a valid fan speed") + percentage_payload = int( + percentage_to_ranged_value(self._speed_range, percentage) + ) + if self._implemented_preset_mode: + if percentage: + await self.async_set_preset_mode( + preset_mode=percentage_to_ordered_list_item( + self.speed_list, percentage + ) + ) + # Legacy are deprecated in the schema, support will be removed after a quarter (2021.7) + elif self._feature_legacy_speeds and ( + SPEED_OFF in self._legacy_speeds_list + ): + await self.async_set_preset_mode(SPEED_OFF) + # Legacy are deprecated in the schema, support will be removed after a quarter (2021.7) + elif self._feature_legacy_speeds: + if percentage: + await self.async_set_speed( + percentage_to_ordered_list_item( + self._legacy_speeds_list_no_off, + percentage, + ) + ) + elif SPEED_OFF in self._legacy_speeds_list: + await self.async_set_speed(SPEED_OFF) + + if self._implemented_percentage: + mqtt.async_publish( + self.hass, + self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], + percentage_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + + if self._optimistic_percentage: + self._percentage = percentage + self.async_write_ha_state() + + 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 + # Legacy are deprecated in the schema, support will be removed after a quarter (2021.7) + if preset_mode in self._legacy_speeds_list: + await self.async_set_speed(speed=preset_mode) + if not self._implemented_percentage and preset_mode in self.speed_list: + self._percentage = ordered_list_item_to_percentage( + self.speed_list, preset_mode + ) + mqtt_payload = preset_mode mqtt.async_publish( self.hass, - self._topic[CONF_SPEED_COMMAND_TOPIC], + self._topic[CONF_PRESET_MODE_COMMAND_TOPIC], mqtt_payload, self._config[CONF_QOS], self._config[CONF_RETAIN], ) - if self._optimistic_speed: - self._speed = speed + if self._optimistic_preset_mode: + self._preset_mode = preset_mode + self.async_write_ha_state() + + # async_set_speed is deprecated, support will be removed after a quarter (2021.7) + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan. + + This method is a coroutine. + """ + speed_payload = None + if self._feature_legacy_speeds: + if speed == SPEED_LOW: + speed_payload = self._payload["SPEED_LOW"] + elif speed == SPEED_MEDIUM: + speed_payload = self._payload["SPEED_MEDIUM"] + elif speed == SPEED_HIGH: + speed_payload = self._payload["SPEED_HIGH"] + elif speed == SPEED_OFF: + speed_payload = self._payload["SPEED_OFF"] + else: + _LOGGER.warning("'%s'is not a valid speed", speed) + return + + if speed_payload: + mqtt.async_publish( + self.hass, + self._topic[CONF_SPEED_COMMAND_TOPIC], + speed_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + + if self._optimistic_speed and speed_payload: + self._speed = speed self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 045b8fdaf0e..e1ce19c970a 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -2,8 +2,10 @@ from unittest.mock import patch import pytest +from voluptuous.error import MultipleInvalid from homeassistant.components import fan +from homeassistant.components.fan import NotValidPresetModeError from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, @@ -58,7 +60,7 @@ async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): assert hass.states.get("fan.test") is None -async def test_controlling_state_via_topic(hass, mqtt_mock): +async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -73,10 +75,27 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): "payload_on": "StAtE_On", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", - "payload_oscillation_off": "OsC_OfF", - "payload_oscillation_on": "OsC_On", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "speed_state_topic": "speed-state-topic", "speed_command_topic": "speed-command-topic", + "payload_oscillation_off": "OsC_OfF", + "payload_oscillation_on": "OsC_On", + "percentage_state_topic": "percentage-state-topic", + "percentage_command_topic": "percentage-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": [ + "medium", + "medium-high", + "high", + "very-high", + "freaking-high", + "silent", + ], + "speed_range_min": 1, + "speed_range_max": 200, + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speeds": ["off", "low"], "payload_off_speed": "speed_OfF", "payload_low_speed": "speed_lOw", "payload_medium_speed": "speed_mEdium", @@ -87,16 +106,16 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", "StAtE_On") state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.state == STATE_ON async_fire_mqtt_message(hass, "state-topic", "StAtE_OfF") state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get("oscillating") is False async_fire_mqtt_message(hass, "oscillation-state-topic", "OsC_On") @@ -107,6 +126,51 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is False + async_fire_mqtt_message(hass, "percentage-state-topic", "0") + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + + async_fire_mqtt_message(hass, "percentage-state-topic", "50") + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 25 + + async_fire_mqtt_message(hass, "percentage-state-topic", "100") + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 + + async_fire_mqtt_message(hass, "percentage-state-topic", "200") + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + + async_fire_mqtt_message(hass, "percentage-state-topic", "202") + assert "not a valid speed within the speed range" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "percentage-state-topic", "invalid") + assert "not a valid speed within the speed range" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "low") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "low" + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "medium") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "medium" + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "very-high") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "very-high" + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "silent") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "silent" + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "ModeUnknown") + assert "not a valid preset mode" in caplog.text + caplog.clear() + + # use of speeds is deprecated, support will be removed after a quarter (2021.7) assert state.attributes.get("speed") == fan.SPEED_OFF async_fire_mqtt_message(hass, "speed-state-topic", "speed_lOw") @@ -114,20 +178,173 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert state.attributes.get("speed") == fan.SPEED_LOW async_fire_mqtt_message(hass, "speed-state-topic", "speed_mEdium") - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_MEDIUM + assert "not a valid speed" in caplog.text + caplog.clear() async_fire_mqtt_message(hass, "speed-state-topic", "speed_High") - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_HIGH + assert "not a valid speed" in caplog.text + caplog.clear() async_fire_mqtt_message(hass, "speed-state-topic", "speed_OfF") state = hass.states.get("fan.test") assert state.attributes.get("speed") == fan.SPEED_OFF + async_fire_mqtt_message(hass, "speed-state-topic", "speed_very_high") + assert "not a valid speed" in caplog.text + caplog.clear() -async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): - """Test the controlling state via topic and JSON message.""" + +async def test_controlling_state_via_topic_with_different_speed_range( + hass, mqtt_mock, caplog +): + """Test the controlling state via topic using an alternate speed range.""" + assert await async_setup_component( + hass, + fan.DOMAIN, + { + fan.DOMAIN: [ + { + "platform": "mqtt", + "name": "test1", + "command_topic": "command-topic", + "percentage_state_topic": "percentage-state-topic1", + "percentage_command_topic": "percentage-command-topic1", + "speed_range_min": 1, + "speed_range_max": 100, + }, + { + "platform": "mqtt", + "name": "test2", + "command_topic": "command-topic", + "percentage_state_topic": "percentage-state-topic2", + "percentage_command_topic": "percentage-command-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "platform": "mqtt", + "name": "test3", + "command_topic": "command-topic", + "percentage_state_topic": "percentage-state-topic3", + "percentage_command_topic": "percentage-command-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ] + }, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "percentage-state-topic1", "100") + state = hass.states.get("fan.test1") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + + async_fire_mqtt_message(hass, "percentage-state-topic2", "100") + state = hass.states.get("fan.test2") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 + + async_fire_mqtt_message(hass, "percentage-state-topic3", "1023") + state = hass.states.get("fan.test3") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + async_fire_mqtt_message(hass, "percentage-state-topic3", "80") + state = hass.states.get("fan.test3") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + + state = hass.states.get("fan.test3") + async_fire_mqtt_message(hass, "percentage-state-topic3", "79") + assert "not a valid speed within the speed range" in caplog.text + caplog.clear() + + +async def test_controlling_state_via_topic_no_percentage_topics(hass, mqtt_mock): + """Test the controlling state via topic without percentage topics.""" + assert await async_setup_component( + hass, + fan.DOMAIN, + { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speed_state_topic": "speed-state-topic", + "speed_command_topic": "speed-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": [ + "high", + "freaking-high", + "silent", + ], + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speeds": ["off", "low", "medium"], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "freaking-high") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "freaking-high" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get("speed") == fan.SPEED_OFF + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "high") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "high" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 75 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get("speed") == fan.SPEED_OFF + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "silent") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "silent" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 75 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get("speed") == fan.SPEED_OFF + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "medium") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "medium" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get("speed") == fan.SPEED_OFF + + async_fire_mqtt_message(hass, "preset-mode-state-topic", "low") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "low" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 25 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get("speed") == fan.SPEED_OFF + + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + async_fire_mqtt_message(hass, "speed-state-topic", "medium") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "low" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 + assert state.attributes.get("speed") == fan.SPEED_MEDIUM + + async_fire_mqtt_message(hass, "speed-state-topic", "low") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "low" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 25 + assert state.attributes.get("speed") == fan.SPEED_LOW + + async_fire_mqtt_message(hass, "speed-state-topic", "off") + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "low" + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + assert state.attributes.get("speed") == fan.SPEED_OFF + + +async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): + """Test the controlling state via topic and JSON message (percentage mode).""" assert await async_setup_component( hass, fan.DOMAIN, @@ -139,27 +356,40 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): "command_topic": "command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", - "speed_state_topic": "speed-state-topic", - "speed_command_topic": "speed-command-topic", + "percentage_state_topic": "percentage-state-topic", + "percentage_command_topic": "percentage-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": [ + "medium", + "medium-high", + "high", + "very-high", + "freaking-high", + "silent", + ], "state_value_template": "{{ value_json.val }}", "oscillation_value_template": "{{ value_json.val }}", - "speed_value_template": "{{ value_json.val }}", + "percentage_value_template": "{{ value_json.val }}", + "preset_mode_value_template": "{{ value_json.val }}", + "speed_range_min": 1, + "speed_range_max": 100, } }, ) await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "state-topic", '{"val":"ON"}') state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.state == STATE_ON async_fire_mqtt_message(hass, "state-topic", '{"val":"OFF"}') state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get("oscillating") is False async_fire_mqtt_message(hass, "oscillation-state-topic", '{"val":"oscillate_on"}') @@ -170,23 +400,29 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is False - assert state.attributes.get("speed") == fan.SPEED_OFF - - async_fire_mqtt_message(hass, "speed-state-topic", '{"val":"low"}') + async_fire_mqtt_message(hass, "percentage-state-topic", '{"val": 1}') state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_LOW + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 1 - async_fire_mqtt_message(hass, "speed-state-topic", '{"val":"medium"}') + async_fire_mqtt_message(hass, "percentage-state-topic", '{"val": 100}') state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_MEDIUM + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 - async_fire_mqtt_message(hass, "speed-state-topic", '{"val":"high"}') - state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_HIGH + async_fire_mqtt_message(hass, "preset-mode-state-topic", '{"val": "low"}') + assert "not a valid preset mode" in caplog.text + caplog.clear() - async_fire_mqtt_message(hass, "speed-state-topic", '{"val":"off"}') + async_fire_mqtt_message(hass, "preset-mode-state-topic", '{"val": "medium"}') state = hass.states.get("fan.test") - assert state.attributes.get("speed") == fan.SPEED_OFF + assert state.attributes.get("preset_mode") == "medium" + + async_fire_mqtt_message(hass, "preset-mode-state-topic", '{"val": "freaking-high"}') + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "freaking-high" + + async_fire_mqtt_message(hass, "preset-mode-state-topic", '{"val": "silent"}') + state = hass.states.get("fan.test") + assert state.attributes.get("preset_mode") == "silent" async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): @@ -202,11 +438,20 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): "payload_off": "StAtE_OfF", "payload_on": "StAtE_On", "oscillation_command_topic": "oscillation-command-topic", - "oscillation_state_topic": "oscillation-state-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "speed_command_topic": "speed-command-topic", - "speed_state_topic": "speed-state-topic", + "percentage_command_topic": "percentage-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speeds": ["off", "low", "medium"], + "preset_modes": [ + "high", + "freaking-high", + "silent", + ], + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "payload_off_speed": "speed_OfF", "payload_low_speed": "speed_lOw", "payload_medium_speed": "speed_mEdium", @@ -217,7 +462,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_on(hass, "fan.test") @@ -226,7 +471,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_off(hass, "fan.test") @@ -235,7 +480,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_oscillate(hass, "fan.test", True) @@ -244,7 +489,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_oscillate(hass, "fan.test", False) @@ -253,48 +498,232 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", -1) + + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", 101) + + await common.async_set_percentage(hass, "fan.test", 100) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "freaking-high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_percentage(hass, "fan.test", 0) + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "0", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "off", 0, False + ) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call( + "speed-command-topic", "speed_OfF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get(fan.ATTR_SPEED) == fan.SPEED_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "low") + assert mqtt_mock.async_publish.call_count == 2 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call( + "speed-command-topic", "speed_lOw", 0, False + ) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "low", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "low" + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get(fan.ATTR_SPEED) == fan.SPEED_LOW + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "medium") + assert mqtt_mock.async_publish.call_count == 2 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call( + "speed-command-topic", "speed_mEdium", 0, False + ) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "medium" + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + assert state.attributes.get(fan.ATTR_SPEED) == fan.SPEED_MEDIUM + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "high" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "freaking-high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "freaking-high" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "silent") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "silent", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "silent" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) mqtt_mock.async_publish.assert_called_once_with( "speed-command-topic", "speed_lOw", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) mqtt_mock.async_publish.assert_called_once_with( "speed-command-topic", "speed_mEdium", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) mqtt_mock.async_publish.assert_called_once_with( "speed-command-topic", "speed_High", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) mqtt_mock.async_publish.assert_called_once_with( "speed-command-topic", "speed_OfF", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_on_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): - """Test on with speed.""" +async def test_sending_mqtt_commands_with_alternate_speed_range(hass, mqtt_mock): + """Test the controlling state via topic using an alternate speed range.""" + assert await async_setup_component( + hass, + fan.DOMAIN, + { + fan.DOMAIN: [ + { + "platform": "mqtt", + "name": "test1", + "command_topic": "command-topic", + "percentage_state_topic": "percentage-state-topic1", + "percentage_command_topic": "percentage-command-topic1", + "speed_range_min": 1, + "speed_range_max": 100, + }, + { + "platform": "mqtt", + "name": "test2", + "command_topic": "command-topic", + "percentage_state_topic": "percentage-state-topic2", + "percentage_command_topic": "percentage-command-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "platform": "mqtt", + "name": "test3", + "command_topic": "command-topic", + "percentage_state_topic": "percentage-state-topic3", + "percentage_command_topic": "percentage-command-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ] + }, + ) + await hass.async_block_till_done() + + await common.async_set_percentage(hass, "fan.test1", 0) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic1", "0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test1") + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test1", 100) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic1", "100", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test1") + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test2", 0) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic2", "0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test2") + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test2", 100) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic2", "200", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test2") + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test3", 0) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic3", "80", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test3") + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test3", 100) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic3", "1023", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test3") + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, caplog): + """Test optimistic mode without state topic without legacy speed command topic.""" assert await async_setup_component( hass, fan.DOMAIN, @@ -303,47 +732,429 @@ async def test_on_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): "platform": "mqtt", "name": "test", "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "speed_command_topic": "speed-command-topic", + "percentage_command_topic": "percentage-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": [ + "high", + "freaking-high", + "silent", + ], } }, ) await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_on(hass, "fan.test") mqtt_mock.async_publish.assert_called_once_with("command-topic", "ON", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) is None - assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_turn_off(hass, "fan.test") mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - await common.async_turn_on(hass, "fan.test", speed="low") - assert mqtt_mock.async_publish.call_count == 2 - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) - mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", -1) + + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", 101) + + await common.async_set_percentage(hass, "fan.test", 100) + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "freaking-high", 0, False + ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "freaking-high" assert state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes.get(fan.ATTR_SPEED) == "low" - assert state.attributes.get(fan.ATTR_OSCILLATING) is None + + await common.async_set_percentage(hass, "fan.test", 0) + mqtt_mock.async_publish.assert_called_once_with( + "percentage-command-topic", "0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + assert state.attributes.get(fan.ATTR_SPEED) is None + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "low") + assert "not a valid preset mode" in caplog.text + caplog.clear() + + await common.async_set_preset_mode(hass, "fan.test", "medium") + assert "not a valid preset mode" in caplog.text + caplog.clear() + + await common.async_set_preset_mode(hass, "fan.test", "high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "high" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "freaking-high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "freaking-high" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "silent") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "silent", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) == "silent" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", percentage=25) + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "25", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_any_call("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", preset_mode="high") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + with pytest.raises(NotValidPresetModeError): + await common.async_turn_on(hass, "fan.test", preset_mode="low") -async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): - """Test optimistic mode with state topic.""" +async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( + hass, mqtt_mock +): + """Test optimistic mode without state topic without percentage command topic.""" + assert await async_setup_component( + hass, + fan.DOMAIN, + { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speed_state_topic": "speed-state-topic", + "speed_command_topic": "speed-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speeds": ["off", "low", "medium"], + "preset_modes": [ + "high", + "freaking-high", + "silent", + ], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", -1) + + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", 101) + + await common.async_set_percentage(hass, "fan.test", 100) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "freaking-high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test", 0) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "low") + assert mqtt_mock.async_publish.call_count == 2 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "low", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) is None + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "medium") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) is None + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) is None + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "freaking-high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) is None + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "silent") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "silent", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PRESET_MODE) is None + assert state.attributes.get(ATTR_ASSUMED_STATE) + + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "low", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) + + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", speed="medium") + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + await common.async_turn_on(hass, "fan.test", speed="high") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +# use of speeds is deprecated, support will be removed after a quarter (2021.7) +async def test_sending_mqtt_commands_and_optimistic_legacy_speeds_only( + hass, mqtt_mock, caplog +): + """Test optimistic mode without state topics with legacy speeds.""" + assert await async_setup_component( + hass, + fan.DOMAIN, + { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "speed_state_topic": "speed-state-topic", + "speed_command_topic": "speed-command-topic", + "speeds": ["off", "low", "medium", "high"], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test", 100) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "high", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 100 + assert state.attributes.get(fan.ATTR_SPEED) == "off" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test", 0) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "low") + assert "not a valid preset mode" in caplog.text + caplog.clear() + + await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "low", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) + mqtt_mock.async_publish.assert_called_once_with( + "speed-command-topic", "off", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", speed="medium") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", speed="off") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, caplog): + """Test optimistic mode with state topic and turn on attributes.""" assert await async_setup_component( hass, fan.DOMAIN, @@ -353,10 +1164,22 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", - "oscillation_state_topic": "oscillation-state-topic", - "oscillation_command_topic": "oscillation-command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "speed_state_topic": "speed-state-topic", "speed_command_topic": "speed-command-topic", + "oscillation_state_topic": "oscillation-state-topic", + "oscillation_command_topic": "oscillation-command-topic", + "percentage_state_topic": "percentage-state-topic", + "percentage_command_topic": "percentage-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_mode_state_topic": "preset-mode-state-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speeds": ["off", "low", "medium"], + "preset_modes": [ + "high", + "freaking-high", + "silent", + ], "optimistic": True, } }, @@ -364,21 +1187,129 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_on(hass, "fan.test") mqtt_mock.async_publish.assert_called_once_with("command-topic", "ON", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_turn_off(hass, "fan.test") mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", speed=fan.SPEED_MEDIUM) + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", percentage=25) + assert mqtt_mock.async_publish.call_count == 4 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "low", 0, False + ) + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "25", 0, False) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_any_call("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", preset_mode="medium") + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", preset_mode="high") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_any_call("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", preset_mode="silent") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "silent", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", preset_mode="silent") + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "silent", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_oscillate(hass, "fan.test", True) @@ -387,7 +1318,28 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_on(hass, "fan.test", percentage=50) + assert mqtt_mock.async_publish.call_count == 4 + mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "50", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_turn_off(hass, "fan.test") + mqtt_mock.async_publish.assert_any_call("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_oscillate(hass, "fan.test", False) @@ -396,25 +1348,121 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) - mqtt_mock.async_publish.assert_called_once_with( - "speed-command-topic", "low", 0, False + await common.async_set_percentage(hass, "fan.test", 33) + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "33", 0, False) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_percentage(hass, "fan.test", 50) + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "50", 0, False) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test", 100) + assert mqtt_mock.async_publish.call_count == 2 + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "100", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "freaking-high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_percentage(hass, "fan.test", 0) + assert mqtt_mock.async_publish.call_count == 3 + mqtt_mock.async_publish.assert_any_call("percentage-command-topic", "0", 0, False) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "off", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "off", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + with pytest.raises(MultipleInvalid): + await common.async_set_percentage(hass, "fan.test", 101) + + await common.async_set_preset_mode(hass, "fan.test", "low") + assert mqtt_mock.async_publish.call_count == 2 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "low", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "low", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "medium") + assert mqtt_mock.async_publish.call_count == 2 + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + mqtt_mock.async_publish.assert_any_call("speed-command-topic", "medium", 0, False) + mqtt_mock.async_publish.assert_any_call( + "preset-mode-command-topic", "medium", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "high") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "high", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "silent") + mqtt_mock.async_publish.assert_called_once_with( + "preset-mode-command-topic", "silent", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_preset_mode(hass, "fan.test", "ModeX") + assert "not a valid preset mode" in caplog.text + caplog.clear() + + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) mqtt_mock.async_publish.assert_called_once_with( "speed-command-topic", "medium", 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) @@ -423,7 +1471,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) @@ -432,14 +1480,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(ValueError): - await common.async_set_speed(hass, "fan.test", "cUsToM") + await common.async_set_speed(hass, "fan.test", "cUsToM") + assert "not a valid speed" in caplog.text + caplog.clear() -async def test_attributes(hass, mqtt_mock): +async def test_attributes(hass, mqtt_mock, caplog): """Test attributes.""" assert await async_setup_component( hass, @@ -450,76 +1499,96 @@ async def test_attributes(hass, mqtt_mock): "name": "test", "command_topic": "command-topic", "oscillation_command_topic": "oscillation-command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "speed_command_topic": "speed-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "percentage_command_topic": "percentage-command-topic", + "preset_modes": [ + "freaking-high", + "silent", + ], } }, ) await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF - assert state.attributes.get(fan.ATTR_SPEED_LIST) == ["off", "low", "medium", "high"] + assert state.state == STATE_OFF + assert state.attributes.get(fan.ATTR_SPEED_LIST) == [ + "low", + "medium", + "high", + "freaking-high", + ] await common.async_turn_on(hass, "fan.test") state = hass.states.get("fan.test") - assert state.state is STATE_ON + assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_turn_off(hass, "fan.test") state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is None await common.async_oscillate(hass, "fan.test", True) state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is True await common.async_oscillate(hass, "fan.test", False) state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + # use of speeds is deprecated, support will be removed after a quarter (2021.7) assert state.attributes.get(fan.ATTR_SPEED) is None assert state.attributes.get(fan.ATTR_OSCILLATING) is False + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_LOW) state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_SPEED) == "low" assert state.attributes.get(fan.ATTR_OSCILLATING) is False + # use of speeds is deprecated, support will be removed after a quarter (2021.7) await common.async_set_speed(hass, "fan.test", fan.SPEED_MEDIUM) state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_SPEED) == "medium" assert state.attributes.get(fan.ATTR_OSCILLATING) is False await common.async_set_speed(hass, "fan.test", fan.SPEED_HIGH) state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_SPEED) == "high" assert state.attributes.get(fan.ATTR_OSCILLATING) is False await common.async_set_speed(hass, "fan.test", fan.SPEED_OFF) state = hass.states.get("fan.test") - assert state.state is STATE_OFF + assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_SPEED) == "off" assert state.attributes.get(fan.ATTR_OSCILLATING) is False - with pytest.raises(ValueError): - await common.async_set_speed(hass, "fan.test", "cUsToM") + await common.async_set_speed(hass, "fan.test", "cUsToM") + assert "not a valid speed" in caplog.text + caplog.clear() +# use of speeds is deprecated, support will be removed after a quarter (2021.7) async def test_custom_speed_list(hass, mqtt_mock): """Test optimistic mode without state topic.""" assert await async_setup_component( @@ -541,8 +1610,8 @@ async def test_custom_speed_list(hass, mqtt_mock): await hass.async_block_till_done() state = hass.states.get("fan.test") - assert state.state is STATE_OFF - assert state.attributes.get(fan.ATTR_SPEED_LIST) == ["off", "high"] + assert state.state == STATE_OFF + assert state.attributes.get(fan.ATTR_SPEED_LIST) == ["high"] async def test_supported_features(hass, mqtt_mock): @@ -565,17 +1634,120 @@ async def test_supported_features(hass, mqtt_mock): }, { "platform": "mqtt", - "name": "test3", + "name": "test3a1", "command_topic": "command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "speed_command_topic": "speed-command-topic", }, + { + "platform": "mqtt", + "name": "test3a2", + "command_topic": "command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speed_command_topic": "speed-command-topic", + "speeds": ["low"], + }, + { + "platform": "mqtt", + "name": "test3a3", + "command_topic": "command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) + "speed_command_topic": "speed-command-topic", + "speeds": ["off"], + }, + { + "platform": "mqtt", + "name": "test3b", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + }, + { + "platform": "mqtt", + "name": "test3c1", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + }, + { + "platform": "mqtt", + "name": "test3c2", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["very-fast", "auto"], + }, + { + "platform": "mqtt", + "name": "test3c3", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["off", "on", "auto"], + }, { "platform": "mqtt", "name": "test4", "command_topic": "command-topic", "oscillation_command_topic": "oscillation-command-topic", + # use of speeds is deprecated, support will be removed after a quarter (2021.7) "speed_command_topic": "speed-command-topic", }, + { + "platform": "mqtt", + "name": "test4pcta", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + }, + { + "platform": "mqtt", + "name": "test4pctb", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "percentage_command_topic": "percentage-command-topic", + }, + { + "platform": "mqtt", + "name": "test5pr_ma", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + }, + { + "platform": "mqtt", + "name": "test5pr_mb", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["off", "on", "auto"], + }, + { + "platform": "mqtt", + "name": "test5pr_mc", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + }, + { + "platform": "mqtt", + "name": "test6spd_range_a", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 1, + "speed_range_max": 40, + }, + { + "platform": "mqtt", + "name": "test6spd_range_b", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 50, + "speed_range_max": 40, + }, + { + "platform": "mqtt", + "name": "test6spd_range_c", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 0, + "speed_range_max": 40, + }, ] }, ) @@ -585,14 +1757,69 @@ async def test_supported_features(hass, mqtt_mock): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 state = hass.states.get("fan.test2") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_OSCILLATE - state = hass.states.get("fan.test3") + + state = hass.states.get("fan.test3a1") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + and fan.SUPPORT_SET_SPEED == fan.SUPPORT_SET_SPEED + ) + state = hass.states.get("fan.test3a2") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + and fan.SUPPORT_SET_SPEED == fan.SUPPORT_SET_SPEED + ) + state = hass.states.get("fan.test3a3") + assert state is None + + state = hass.states.get("fan.test3b") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED + + state = hass.states.get("fan.test3c1") + assert state is None + + state = hass.states.get("fan.test3c2") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == fan.SUPPORT_PRESET_MODE | fan.SUPPORT_SET_SPEED + ) + state = hass.states.get("fan.test3c3") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_PRESET_MODE + state = hass.states.get("fan.test4") assert ( state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED ) + state = hass.states.get("fan.test4pcta") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED + state = hass.states.get("fan.test4pctb") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED + ) + + state = hass.states.get("fan.test5pr_ma") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == fan.SUPPORT_SET_SPEED | fan.SUPPORT_PRESET_MODE + ) + state = hass.states.get("fan.test5pr_mb") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_PRESET_MODE + + state = hass.states.get("fan.test5pr_mc") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED | fan.SUPPORT_PRESET_MODE + ) + + state = hass.states.get("fan.test6spd_range_a") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_SET_SPEED + state = hass.states.get("fan.test6spd_range_b") + assert state is None + state = hass.states.get("fan.test6spd_range_c") + assert state is None + async def test_availability_when_connection_lost(hass, mqtt_mock): """Test availability after MQTT disconnection.""" @@ -643,7 +1870,7 @@ async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG