diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index cc1f86d285a..de593385c1f 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -50,6 +50,10 @@ ABBREVIATIONS = { "curr_temp_tpl": "current_temperature_template", "dev": "device", "dev_cla": "device_class", + "dir_cmd_t": "direction_command_topic", + "dir_cmd_tpl": "direction_command_template", + "dir_stat_t": "direction_state_topic", + "dir_val_tpl": "direction_value_template", "dock_t": "docked_topic", "dock_tpl": "docked_template", "e": "encoding", diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 74290abb757..e8259c60809 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import fan from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, @@ -56,6 +57,7 @@ from .mixins import ( warn_for_legacy_schema, ) from .models import ( + MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, @@ -64,6 +66,10 @@ from .models import ( ) from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic +CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" +CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" +CONF_DIRECTION_VALUE_TEMPLATE = "direction_value_template" +CONF_DIRECTION_COMMAND_TEMPLATE = "direction_command_template" CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" CONF_PERCENTAGE_VALUE_TEMPLATE = "percentage_value_template" @@ -128,6 +134,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_DIRECTION_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_DIRECTION_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_OSCILLATION_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATION_STATE_TOPIC): valid_subscribe_topic, @@ -225,6 +235,7 @@ class MqttFan(MqttEntity, FanEntity): _feature_preset_mode: bool _topic: dict[str, Any] _optimistic: bool + _optimistic_direction: bool _optimistic_oscillation: bool _optimistic_percentage: bool _optimistic_preset_mode: bool @@ -260,6 +271,8 @@ class MqttFan(MqttEntity, FanEntity): for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, + CONF_DIRECTION_COMMAND_TOPIC, CONF_PERCENTAGE_STATE_TOPIC, CONF_PERCENTAGE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, @@ -292,6 +305,9 @@ class MqttFan(MqttEntity, FanEntity): optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._optimistic_direction = ( + optimistic or self._topic[CONF_DIRECTION_STATE_TOPIC] is None + ) self._optimistic_oscillation = ( optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None ) @@ -307,6 +323,10 @@ class MqttFan(MqttEntity, FanEntity): self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is not None and FanEntityFeature.OSCILLATE ) + self._attr_supported_features |= ( + self._topic[CONF_DIRECTION_COMMAND_TOPIC] is not None + and FanEntityFeature.DIRECTION + ) if self._feature_percentage: self._attr_supported_features |= FanEntityFeature.SET_SPEED if self._feature_preset_mode: @@ -314,6 +334,7 @@ class MqttFan(MqttEntity, FanEntity): command_templates: dict[str, Template | None] = { CONF_STATE: config.get(CONF_COMMAND_TEMPLATE), + ATTR_DIRECTION: config.get(CONF_DIRECTION_COMMAND_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_COMMAND_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_COMMAND_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_COMMAND_TEMPLATE), @@ -327,6 +348,7 @@ class MqttFan(MqttEntity, FanEntity): self._value_templates = {} value_templates: dict[str, Template | None] = { CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_DIRECTION: config.get(CONF_DIRECTION_VALUE_TEMPLATE), ATTR_PERCENTAGE: config.get(CONF_PERCENTAGE_VALUE_TEMPLATE), ATTR_PRESET_MODE: config.get(CONF_PRESET_MODE_VALUE_TEMPLATE), ATTR_OSCILLATING: config.get(CONF_OSCILLATION_VALUE_TEMPLATE), @@ -341,6 +363,17 @@ class MqttFan(MqttEntity, FanEntity): """(Re)Subscribe to topics.""" topics: dict[str, Any] = {} + def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: + """Add a topic to subscribe to.""" + if has_topic := self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + return has_topic + @callback @log_messages(self.hass, self.entity_id) def state_received(msg: ReceiveMessage) -> None: @@ -357,13 +390,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_is_on = None get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + add_subscribe_topic(CONF_STATE_TOPIC, state_received) @callback @log_messages(self.hass, self.entity_id) @@ -408,14 +435,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_percentage = percentage get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - 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], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_percentage = None + add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) @callback @log_messages(self.hass, self.entity_id) @@ -441,14 +461,7 @@ class MqttFan(MqttEntity, FanEntity): self._attr_preset_mode = preset_mode get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - 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], - "encoding": self._config[CONF_ENCODING] or None, - } - self._attr_preset_mode = None + add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) @callback @log_messages(self.hass, self.entity_id) @@ -464,15 +477,22 @@ class MqttFan(MqttEntity, FanEntity): self._attr_oscillating = False get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: - topics[CONF_OSCILLATION_STATE_TOPIC] = { - "topic": self._topic[CONF_OSCILLATION_STATE_TOPIC], - "msg_callback": oscillation_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): self._attr_oscillating = False + @callback + @log_messages(self.hass, self.entity_id) + def direction_received(msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the direction.""" + direction = self._value_templates[ATTR_DIRECTION](msg.payload) + if not direction: + _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) + return + self._attr_current_direction = str(direction) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) + self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) @@ -602,3 +622,22 @@ class MqttFan(MqttEntity, FanEntity): if self._optimistic_oscillation: self._attr_oscillating = oscillating self.async_write_ha_state() + + async def async_set_direction(self, direction: str) -> None: + """Set direction. + + This method is a coroutine. + """ + mqtt_payload = self._command_templates[ATTR_DIRECTION](direction) + + await self.async_publish( + self._topic[CONF_DIRECTION_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + if self._optimistic_direction: + self._attr_current_direction = direction + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 264165a2448..c274c18bec0 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -8,6 +8,7 @@ from voluptuous.error import MultipleInvalid from homeassistant.components import fan, mqtt from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE, @@ -15,6 +16,8 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.components.mqtt.fan import ( + CONF_DIRECTION_COMMAND_TOPIC, + CONF_DIRECTION_STATE_TOPIC, CONF_OSCILLATION_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, CONF_PERCENTAGE_COMMAND_TOPIC, @@ -111,6 +114,8 @@ async def test_fail_setup_if_no_command_topic( "command_topic": "command-topic", "payload_off": "StAtE_OfF", "payload_on": "StAtE_On", + "direction_state_topic": "direction-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "payload_oscillation_off": "OsC_OfF", @@ -157,6 +162,14 @@ async def test_controlling_state_via_topic( assert state.state == STATE_OFF assert state.attributes.get("oscillating") is False + async_fire_mqtt_message(hass, "direction-state-topic", "forward") + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "forward" + + async_fire_mqtt_message(hass, "direction-state-topic", "reverse") + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "reverse" + async_fire_mqtt_message(hass, "oscillation-state-topic", "OsC_On") state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is True @@ -357,6 +370,8 @@ async def test_controlling_state_via_topic_no_percentage_topics( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", + "direction_state_topic": "direction-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "percentage-state-topic", @@ -372,6 +387,7 @@ async def test_controlling_state_via_topic_no_percentage_topics( "silent", ], "state_value_template": "{{ value_json.val }}", + "direction_value_template": "{{ value_json.val }}", "oscillation_value_template": "{{ value_json.val }}", "percentage_value_template": "{{ value_json.val }}", "preset_mode_value_template": "{{ value_json.val }}", @@ -407,6 +423,14 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_OFF assert state.attributes.get("oscillating") is False + async_fire_mqtt_message(hass, "direction-state-topic", '{"val":"forward"}') + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "forward" + + async_fire_mqtt_message(hass, "direction-state-topic", '{"val":"reverse"}') + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "reverse" + async_fire_mqtt_message(hass, "oscillation-state-topic", '{"val":"oscillate_on"}') state = hass.states.get("fan.test") assert state.attributes.get("oscillating") is True @@ -464,6 +488,8 @@ async def test_controlling_state_via_topic_and_json_message( "name": "test", "state_topic": "shared-state-topic", "command_topic": "command-topic", + "direction_state_topic": "shared-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "shared-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "shared-state-topic", @@ -479,6 +505,7 @@ async def test_controlling_state_via_topic_and_json_message( "silent", ], "state_value_template": "{{ value_json.state }}", + "direction_value_template": "{{ value_json.direction }}", "oscillation_value_template": "{{ value_json.oscillation }}", "percentage_value_template": "{{ value_json.percentage }}", "preset_mode_value_template": "{{ value_json.preset_mode }}", @@ -499,15 +526,23 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN + assert state.attributes.get("direction") is None assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message( hass, "shared-state-topic", - '{"state":"ON","preset_mode":"eco","oscillation":"oscillate_on","percentage": 50}', + """{ + "state":"ON", + "preset_mode":"eco", + "oscillation":"oscillate_on", + "percentage": 50, + "direction": "forward" + }""", ) state = hass.states.get("fan.test") assert state.state == STATE_ON + assert state.attributes.get("direction") == "forward" assert state.attributes.get("oscillating") is True assert state.attributes.get(fan.ATTR_PERCENTAGE) == 50 assert state.attributes.get("preset_mode") == "eco" @@ -515,10 +550,17 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( async_fire_mqtt_message( hass, "shared-state-topic", - '{"state":"ON","preset_mode":"auto","oscillation":"oscillate_off","percentage": 10}', + """{ + "state":"ON", + "preset_mode":"auto", + "oscillation":"oscillate_off", + "percentage": 10, + "direction": "forward" + }""", ) state = hass.states.get("fan.test") assert state.state == STATE_ON + assert state.attributes.get("direction") == "forward" assert state.attributes.get("oscillating") is False assert state.attributes.get(fan.ATTR_PERCENTAGE) == 10 assert state.attributes.get("preset_mode") == "auto" @@ -526,10 +568,17 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( async_fire_mqtt_message( hass, "shared-state-topic", - '{"state":"OFF","preset_mode":"auto","oscillation":"oscillate_off","percentage": 0}', + """{ + "state":"OFF", + "preset_mode":"auto", + "oscillation":"oscillate_off", + "percentage": 0, + "direction": "reverse" + }""", ) state = hass.states.get("fan.test") assert state.state == STATE_OFF + assert state.attributes.get("direction") == "reverse" assert state.attributes.get("oscillating") is False assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get("preset_mode") == "auto" @@ -555,6 +604,7 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "command_topic": "command-topic", "payload_off": "StAtE_OfF", "payload_on": "StAtE_On", + "direction_command_topic": "direction-command-topic", "oscillation_command_topic": "oscillation-command-topic", "payload_oscillation_off": "OsC_OfF", "payload_oscillation_on": "OsC_On", @@ -599,6 +649,24 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "forward") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "forward", 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_direction(hass, "fan.test", "reverse") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "reverse", 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) mqtt_mock.async_publish.assert_called_once_with( "oscillation-command-topic", "OsC_On", 0, False @@ -924,6 +992,8 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( "name": "test", "command_topic": "command-topic", "command_template": "state: {{ value }}", + "direction_command_topic": "direction-command-topic", + "direction_command_template": "direction: {{ value }}", "oscillation_command_topic": "oscillation-command-topic", "oscillation_command_template": "oscillation: {{ value }}", "percentage_command_topic": "percentage-command-topic", @@ -969,6 +1039,24 @@ async def test_sending_mqtt_command_templates_( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "forward") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "direction: forward", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "forward" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await common.async_set_direction(hass, "fan.test", "reverse") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "direction: reverse", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("fan.test") + assert state.attributes.get("direction") == "reverse" + assert state.attributes.get(ATTR_ASSUMED_STATE) + with pytest.raises(MultipleInvalid): await common.async_set_percentage(hass, "fan.test", -1) @@ -1131,6 +1219,8 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( "name": "test", "state_topic": "state-topic", "command_topic": "command-topic", + "direction_state_topic": "direction-state-topic", + "direction_command_topic": "direction-command-topic", "oscillation_state_topic": "oscillation-state-topic", "oscillation_command_topic": "oscillation-command-topic", "percentage_state_topic": "percentage-state-topic", @@ -1250,6 +1340,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "forward") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "forward", 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) mqtt_mock.async_publish.assert_called_once_with( "oscillation-command-topic", "oscillate_on", 0, False @@ -1275,6 +1374,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) + await common.async_set_direction(hass, "fan.test", "reverse") + mqtt_mock.async_publish.assert_called_once_with( + "direction-command-topic", "reverse", 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) mqtt_mock.async_publish.assert_called_once_with( "oscillation-command-topic", "oscillate_off", 0, False @@ -1368,6 +1476,12 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ATTR_OSCILLATING, True, ), + ( + CONF_DIRECTION_STATE_TOPIC, + "reverse", + ATTR_DIRECTION, + "reverse", + ), ], ) async def test_encoding_subscribable_topics( @@ -1383,6 +1497,7 @@ async def test_encoding_subscribable_topics( config[ATTR_PRESET_MODES] = ["eco", "auto"] config[CONF_PRESET_MODE_COMMAND_TOPIC] = "fan/some_preset_mode_command_topic" config[CONF_PERCENTAGE_COMMAND_TOPIC] = "fan/some_percentage_command_topic" + config[CONF_DIRECTION_COMMAND_TOPIC] = "fan/some_direction_command_topic" config[CONF_OSCILLATION_COMMAND_TOPIC] = "fan/some_oscillation_command_topic" await help_test_encoding_subscribable_topics( hass, @@ -1404,6 +1519,7 @@ async def test_encoding_subscribable_topics( fan.DOMAIN: { "name": "test", "command_topic": "command-topic", + "direction_command_topic": "direction-command-topic", "oscillation_command_topic": "oscillation-command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "percentage_command_topic": "percentage-command-topic", @@ -1432,18 +1548,28 @@ async def test_attributes( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is None + assert state.attributes.get(fan.ATTR_DIRECTION) is None await common.async_turn_off(hass, "fan.test") state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is None + assert state.attributes.get(fan.ATTR_DIRECTION) is None await common.async_oscillate(hass, "fan.test", True) state = hass.states.get("fan.test") assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is True + assert state.attributes.get(fan.ATTR_DIRECTION) is None + + await common.async_set_direction(hass, "fan.test", "reverse") + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(fan.ATTR_OSCILLATING) is True + assert state.attributes.get(fan.ATTR_DIRECTION) == "reverse" await common.async_oscillate(hass, "fan.test", False) state = hass.states.get("fan.test") @@ -1451,6 +1577,13 @@ async def test_attributes( assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(fan.ATTR_OSCILLATING) is False + await common.async_set_direction(hass, "fan.test", "forward") + state = hass.states.get("fan.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(fan.ATTR_OSCILLATING) is False + assert state.attributes.get(fan.ATTR_DIRECTION) == "forward" + @pytest.mark.parametrize( ("name", "hass_config", "success", "features"), @@ -1694,6 +1827,20 @@ async def test_attributes( True, fan.FanEntityFeature.PRESET_MODE, ), + ( + "test17", + { + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test17", + "command_topic": "command-topic", + "direction_command_topic": "direction-command-topic", + } + } + }, + True, + fan.FanEntityFeature.DIRECTION, + ), ], ) async def test_supported_features( @@ -2027,6 +2174,13 @@ async def test_entity_debug_info_message( "oscillate_on", "oscillation_command_template", ), + ( + fan.SERVICE_SET_DIRECTION, + "direction_command_topic", + {fan.ATTR_DIRECTION: "forward"}, + "forward", + "direction_command_template", + ), ], ) async def test_publishing_with_custom_encoding(