From f3007b22c473b7859026bfaad22b777d7199cee4 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 3 Oct 2022 14:22:05 -0400 Subject: [PATCH] Allow setting set_percentage and set_preset_mode of template fan without turning on (#75656) * decouple set_percentage and set_preset_mode from entity state * correct set_percent self._state logic * Add tests * remove _VALUD_STATES * decouple percent and preset_mode --- homeassistant/components/template/fan.py | 73 +++++++------ tests/components/template/test_fan.py | 126 +++++++++++++++++++++-- 2 files changed, 160 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index b60e7f53364..b27a6ee3e51 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -22,7 +22,6 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -58,7 +57,6 @@ CONF_SET_OSCILLATING_ACTION = "set_oscillating" CONF_SET_DIRECTION_ACTION = "set_direction" CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" -_VALID_STATES = [STATE_ON, STATE_OFF] _VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] FAN_SCHEMA = vol.All( @@ -66,7 +64,7 @@ FAN_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, @@ -144,7 +142,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) friendly_name = self._attr_name - self._template = config[CONF_VALUE_TEMPLATE] + self._template = config.get(CONF_VALUE_TEMPLATE) self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE) self._preset_mode_template = config.get(CONF_PRESET_MODE_TEMPLATE) self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) @@ -178,7 +176,7 @@ class TemplateFan(TemplateEntity, FanEntity): hass, set_direction_action, friendly_name, DOMAIN ) - self._state = STATE_OFF + self._state: bool | None = False self._percentage = None self._preset_mode = None self._oscillating = None @@ -215,9 +213,9 @@ class TemplateFan(TemplateEntity, FanEntity): return self._preset_modes @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if device is on.""" - return self._state == STATE_ON + return self._state @property def preset_mode(self) -> str | None: @@ -254,23 +252,27 @@ class TemplateFan(TemplateEntity, FanEntity): }, context=self._context, ) - self._state = STATE_ON if preset_mode is not None: await self.async_set_preset_mode(preset_mode) elif percentage is not None: await self.async_set_percentage(percentage) + if self._template is None: + self._state = True + self.async_write_ha_state() + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self.async_run_script(self._off_script, context=self._context) - self._state = STATE_OFF + + if self._template is None: + self._state = False + self.async_write_ha_state() async def async_set_percentage(self, percentage: int) -> None: """Set the percentage speed of the fan.""" - self._state = STATE_OFF if percentage == 0 else STATE_ON self._percentage = percentage - self._preset_mode = None if self._set_percentage_script: await self.async_run_script( @@ -279,6 +281,10 @@ class TemplateFan(TemplateEntity, FanEntity): context=self._context, ) + if self._template is None: + self._state = percentage != 0 + self.async_write_ha_state() + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" if self.preset_modes and preset_mode not in self.preset_modes: @@ -290,9 +296,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) return - self._state = STATE_ON self._preset_mode = preset_mode - self._percentage = None if self._set_preset_mode_script: await self.async_run_script( @@ -301,6 +305,10 @@ class TemplateFan(TemplateEntity, FanEntity): context=self._context, ) + if self._template is None: + self._state = True + self.async_write_ha_state() + async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" if self._set_oscillating_script is None: @@ -340,23 +348,23 @@ class TemplateFan(TemplateEntity, FanEntity): self._state = None return - # Validate state - if result in _VALID_STATES: + if isinstance(result, bool): self._state = result - elif result in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._state = None - else: - _LOGGER.error( - "Received invalid fan is_on state: %s for entity %s. Expected: %s", - result, - self.entity_id, - ", ".join(_VALID_STATES), - ) - self._state = None + return + + if isinstance(result, str): + self._state = result.lower() in ("true", STATE_ON) + return + + self._state = False async def async_added_to_hass(self) -> None: """Register callbacks.""" - self.add_template_attribute("_state", self._template, None, self._update_state) + if self._template: + self.add_template_attribute( + "_state", self._template, None, self._update_state + ) + if self._preset_mode_template is not None: self.add_template_attribute( "_preset_mode", @@ -396,19 +404,17 @@ class TemplateFan(TemplateEntity, FanEntity): # Validate percentage try: percentage = int(float(percentage)) - except ValueError: + except (ValueError, TypeError): _LOGGER.error( "Received invalid percentage: %s for entity %s", percentage, self.entity_id, ) self._percentage = 0 - self._preset_mode = None return if 0 <= percentage <= 100: self._percentage = percentage - self._preset_mode = None else: _LOGGER.error( "Received invalid percentage: %s for entity %s", @@ -416,7 +422,6 @@ class TemplateFan(TemplateEntity, FanEntity): self.entity_id, ) self._percentage = 0 - self._preset_mode = None @callback def _update_preset_mode(self, preset_mode): @@ -424,10 +429,8 @@ class TemplateFan(TemplateEntity, FanEntity): preset_mode = str(preset_mode) if self.preset_modes and preset_mode in self.preset_modes: - self._percentage = None self._preset_mode = preset_mode elif preset_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self._percentage = None self._preset_mode = None else: _LOGGER.error( @@ -436,7 +439,6 @@ class TemplateFan(TemplateEntity, FanEntity): self.entity_id, self.preset_mode, ) - self._percentage = None self._preset_mode = None @callback @@ -471,3 +473,8 @@ class TemplateFan(TemplateEntity, FanEntity): ", ".join(_VALID_DIRECTIONS), ) self._direction = None + + @property + def assumed_state(self) -> bool: + """State is assumed, if no template given.""" + return self._template is None diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 30bb9e00d59..503def425c2 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -491,7 +491,7 @@ async def test_set_percentage(hass, calls): for state, value in [ (STATE_ON, 100), (STATE_ON, 66), - (STATE_OFF, 0), + (STATE_ON, 0), ]: await common.async_set_percentage(hass, _TEST_FAN, value) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value @@ -516,7 +516,7 @@ async def test_increase_decrease_speed(hass, calls): (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), (common.async_decrease_speed, None, STATE_ON, 33), - (common.async_decrease_speed, None, STATE_OFF, 0), + (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), ]: await func(hass, _TEST_FAN, extra) @@ -524,6 +524,116 @@ async def test_increase_decrease_speed(hass, calls): _verify(hass, state, value, None, None, None) +async def test_no_value_template(hass, calls): + """Test a fan without a value_template.""" + await _register_fan_sources(hass) + + with assert_setup_component(1, "fan"): + test_fan_config = { + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "percentage_template": "{{ states('input_number.percentage') }}", + "oscillating_template": "{{ states('input_select.osc') }}", + "direction_template": "{{ states('input_select.direction') }}", + "turn_on": [ + { + "service": "input_boolean.turn_on", + "entity_id": _STATE_INPUT_BOOLEAN, + }, + { + "service": "test.automation", + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + }, + ], + "turn_off": [ + { + "service": "input_boolean.turn_off", + "entity_id": _STATE_INPUT_BOOLEAN, + }, + { + "service": "test.automation", + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + }, + ], + "set_preset_mode": [ + { + "service": "input_select.select_option", + "data_template": { + "entity_id": _PRESET_MODE_INPUT_SELECT, + "option": "{{ preset_mode }}", + }, + }, + { + "service": "test.automation", + "data_template": { + "action": "set_preset_mode", + "caller": "{{ this.entity_id }}", + "option": "{{ preset_mode }}", + }, + }, + ], + "set_percentage": [ + { + "service": "input_number.set_value", + "data_template": { + "entity_id": _PERCENTAGE_INPUT_NUMBER, + "value": "{{ percentage }}", + }, + }, + { + "service": "test.automation", + "data_template": { + "action": "set_value", + "caller": "{{ this.entity_id }}", + "value": "{{ percentage }}", + }, + }, + ], + } + assert await setup.async_setup_component( + hass, + "fan", + {"fan": {"platform": "template", "fans": {"test_fan": test_fan_config}}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + await common.async_turn_on(hass, _TEST_FAN) + _verify(hass, STATE_ON, 0, None, None, None) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, 0, None, None, None) + + percent = 100 + await common.async_set_percentage(hass, _TEST_FAN, percent) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent + _verify(hass, STATE_ON, percent, None, None, None) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, percent, None, None, None) + + preset = "auto" + await common.async_set_preset_mode(hass, _TEST_FAN, preset) + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == preset + _verify(hass, STATE_ON, percent, None, None, preset) + + await common.async_turn_off(hass, _TEST_FAN) + _verify(hass, STATE_OFF, percent, None, None, preset) + + await common.async_set_direction(hass, _TEST_FAN, True) + _verify(hass, STATE_OFF, percent, None, None, preset) + + await common.async_oscillate(hass, _TEST_FAN, True) + _verify(hass, STATE_OFF, percent, None, None, preset) + + async def test_increase_decrease_speed_default_speed_count(hass, calls): """Test set valid increase and decrease speed.""" await _register_components(hass) @@ -585,10 +695,7 @@ def _verify( assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode -async def _register_components( - hass, speed_list=None, preset_modes=None, speed_count=None -): - """Register basic components for testing.""" +async def _register_fan_sources(hass): with assert_setup_component(1, "input_boolean"): assert await setup.async_setup_component( hass, "input_boolean", {"input_boolean": {"state": None}} @@ -630,6 +737,13 @@ async def _register_components( }, ) + +async def _register_components( + hass, speed_list=None, preset_modes=None, speed_count=None +): + """Register basic components for testing.""" + await _register_fan_sources(hass) + with assert_setup_component(1, "fan"): value_template = """ {% if is_state('input_boolean.state', 'on') %}