Skip to content

Commit 8421ca7

Browse files
authored
Add assumed optimistic state to template select (home-assistant#148513)
1 parent 124931b commit 8421ca7

File tree

2 files changed

+179
-67
lines changed

2 files changed

+179
-67
lines changed

homeassistant/components/template/select.py

Lines changed: 77 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6-
from typing import Any
6+
from typing import TYPE_CHECKING, Any
77

88
import voluptuous as vol
99

@@ -32,6 +32,7 @@
3232

3333
from . import TriggerUpdateCoordinator
3434
from .const import DOMAIN
35+
from .entity import AbstractTemplateEntity
3536
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
3637
from .trigger_entity import TriggerEntity
3738

@@ -45,7 +46,7 @@
4546

4647
SELECT_SCHEMA = vol.Schema(
4748
{
48-
vol.Required(CONF_STATE): cv.template,
49+
vol.Optional(CONF_STATE): cv.template,
4950
vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
5051
vol.Required(ATTR_OPTIONS): cv.template,
5152
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
@@ -116,7 +117,37 @@ async def async_setup_entry(
116117
async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)])
117118

118119

119-
class TemplateSelect(TemplateEntity, SelectEntity):
120+
class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
121+
"""Representation of a template select features."""
122+
123+
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
124+
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
125+
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
126+
"""Initialize the features."""
127+
self._template = config.get(CONF_STATE)
128+
129+
self._options_template = config[ATTR_OPTIONS]
130+
131+
self._attr_assumed_state = self._optimistic = (
132+
self._template is None or config.get(CONF_OPTIMISTIC, DEFAULT_OPTIMISTIC)
133+
)
134+
self._attr_options = []
135+
self._attr_current_option = None
136+
137+
async def async_select_option(self, option: str) -> None:
138+
"""Change the selected option."""
139+
if self._optimistic:
140+
self._attr_current_option = option
141+
self.async_write_ha_state()
142+
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
143+
await self.async_run_script(
144+
select_option,
145+
run_variables={ATTR_OPTION: option},
146+
context=self._context,
147+
)
148+
149+
150+
class TemplateSelect(TemplateEntity, AbstractTemplateSelect):
120151
"""Representation of a template select."""
121152

122153
_attr_should_poll = False
@@ -128,16 +159,16 @@ def __init__(
128159
unique_id: str | None,
129160
) -> None:
130161
"""Initialize the select."""
131-
super().__init__(hass, config=config, unique_id=unique_id)
132-
assert self._attr_name is not None
133-
self._value_template = config[CONF_STATE]
134-
# Scripts can be an empty list, therefore we need to check for None
162+
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
163+
AbstractTemplateSelect.__init__(self, config)
164+
165+
name = self._attr_name
166+
if TYPE_CHECKING:
167+
assert name is not None
168+
135169
if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
136-
self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN)
137-
self._options_template = config[ATTR_OPTIONS]
138-
self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False)
139-
self._attr_options = []
140-
self._attr_current_option = None
170+
self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN)
171+
141172
self._attr_device_info = async_device_info_to_link_from_device_id(
142173
hass,
143174
config.get(CONF_DEVICE_ID),
@@ -146,12 +177,13 @@ def __init__(
146177
@callback
147178
def _async_setup_templates(self) -> None:
148179
"""Set up templates."""
149-
self.add_template_attribute(
150-
"_attr_current_option",
151-
self._value_template,
152-
validator=cv.string,
153-
none_on_template_error=True,
154-
)
180+
if self._template is not None:
181+
self.add_template_attribute(
182+
"_attr_current_option",
183+
self._template,
184+
validator=cv.string,
185+
none_on_template_error=True,
186+
)
155187
self.add_template_attribute(
156188
"_attr_options",
157189
self._options_template,
@@ -160,24 +192,11 @@ def _async_setup_templates(self) -> None:
160192
)
161193
super()._async_setup_templates()
162194

163-
async def async_select_option(self, option: str) -> None:
164-
"""Change the selected option."""
165-
if self._optimistic:
166-
self._attr_current_option = option
167-
self.async_write_ha_state()
168-
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
169-
await self.async_run_script(
170-
select_option,
171-
run_variables={ATTR_OPTION: option},
172-
context=self._context,
173-
)
174-
175195

176-
class TriggerSelectEntity(TriggerEntity, SelectEntity):
196+
class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
177197
"""Select entity based on trigger data."""
178198

179199
domain = SELECT_DOMAIN
180-
extra_template_keys = (CONF_STATE,)
181200
extra_template_keys_complex = (ATTR_OPTIONS,)
182201

183202
def __init__(
@@ -187,7 +206,12 @@ def __init__(
187206
config: dict,
188207
) -> None:
189208
"""Initialize the entity."""
190-
super().__init__(hass, coordinator, config)
209+
TriggerEntity.__init__(self, hass, coordinator, config)
210+
AbstractTemplateSelect.__init__(self, config)
211+
212+
if CONF_STATE in config:
213+
self._to_render_simple.append(CONF_STATE)
214+
191215
# Scripts can be an empty list, therefore we need to check for None
192216
if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
193217
self.add_script(
@@ -197,24 +221,26 @@ def __init__(
197221
DOMAIN,
198222
)
199223

200-
@property
201-
def current_option(self) -> str | None:
202-
"""Return the currently selected option."""
203-
return self._rendered.get(CONF_STATE)
224+
def _handle_coordinator_update(self):
225+
"""Handle updated data from the coordinator."""
226+
self._process_data()
204227

205-
@property
206-
def options(self) -> list[str]:
207-
"""Return the list of available options."""
208-
return self._rendered.get(ATTR_OPTIONS, [])
228+
if not self.available:
229+
self.async_write_ha_state()
230+
return
209231

210-
async def async_select_option(self, option: str) -> None:
211-
"""Change the selected option."""
212-
if self._config[CONF_OPTIMISTIC]:
213-
self._attr_current_option = option
232+
write_ha_state = False
233+
if (options := self._rendered.get(ATTR_OPTIONS)) is not None:
234+
self._attr_options = vol.All(cv.ensure_list, [cv.string])(options)
235+
write_ha_state = True
236+
237+
if (state := self._rendered.get(CONF_STATE)) is not None:
238+
self._attr_current_option = cv.string(state)
239+
write_ha_state = True
240+
241+
if len(self._rendered) > 0:
242+
# In case any non optimistic template
243+
write_ha_state = True
244+
245+
if write_ha_state:
214246
self.async_write_ha_state()
215-
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
216-
await self.async_run_script(
217-
select_option,
218-
run_variables={ATTR_OPTION: option},
219-
context=self._context,
220-
)

tests/components/template/test_select.py

Lines changed: 102 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
ATTR_ICON,
2929
CONF_ENTITY_ID,
3030
CONF_ICON,
31+
STATE_UNAVAILABLE,
3132
STATE_UNKNOWN,
3233
)
3334
from homeassistant.core import Context, HomeAssistant, ServiceCall
@@ -43,11 +44,15 @@
4344
# Represent for select's current_option
4445
_OPTION_INPUT_SELECT = "input_select.option"
4546
TEST_STATE_ENTITY_ID = "select.test_state"
46-
47+
TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability"
4748
TEST_STATE_TRIGGER = {
4849
"trigger": {
4950
"trigger": "state",
50-
"entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID],
51+
"entity_id": [
52+
_OPTION_INPUT_SELECT,
53+
TEST_STATE_ENTITY_ID,
54+
TEST_AVAILABILITY_ENTITY_ID,
55+
],
5156
},
5257
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
5358
"action": [
@@ -201,20 +206,6 @@ async def test_multiple_configs(hass: HomeAssistant) -> None:
201206

202207
async def test_missing_required_keys(hass: HomeAssistant) -> None:
203208
"""Test: missing required fields will fail."""
204-
with assert_setup_component(0, "template"):
205-
assert await setup.async_setup_component(
206-
hass,
207-
"template",
208-
{
209-
"template": {
210-
"select": {
211-
"select_option": {"service": "script.select_option"},
212-
"options": "{{ ['a', 'b'] }}",
213-
}
214-
}
215-
},
216-
)
217-
218209
with assert_setup_component(0, "select"):
219210
assert await setup.async_setup_component(
220211
hass,
@@ -559,3 +550,98 @@ async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None:
559550

560551
state = hass.states.get(_TEST_SELECT)
561552
assert state.state == "a"
553+
554+
555+
@pytest.mark.parametrize(
556+
("count", "select_config"),
557+
[
558+
(
559+
1,
560+
{
561+
"options": "{{ ['test', 'yes', 'no'] }}",
562+
"select_option": [],
563+
},
564+
)
565+
],
566+
)
567+
@pytest.mark.parametrize(
568+
"style",
569+
[ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
570+
)
571+
@pytest.mark.usefixtures("setup_select")
572+
async def test_optimistic(hass: HomeAssistant) -> None:
573+
"""Test configuration with optimistic state."""
574+
575+
state = hass.states.get(_TEST_SELECT)
576+
assert state.state == STATE_UNKNOWN
577+
578+
# Ensure Trigger template entities update.
579+
hass.states.async_set(TEST_STATE_ENTITY_ID, "anything")
580+
await hass.async_block_till_done()
581+
582+
await hass.services.async_call(
583+
select.DOMAIN,
584+
select.SERVICE_SELECT_OPTION,
585+
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"},
586+
blocking=True,
587+
)
588+
589+
state = hass.states.get(_TEST_SELECT)
590+
assert state.state == "test"
591+
592+
await hass.services.async_call(
593+
select.DOMAIN,
594+
select.SERVICE_SELECT_OPTION,
595+
{ATTR_ENTITY_ID: _TEST_SELECT, "option": "yes"},
596+
blocking=True,
597+
)
598+
599+
state = hass.states.get(_TEST_SELECT)
600+
assert state.state == "yes"
601+
602+
603+
@pytest.mark.parametrize(
604+
("count", "select_config"),
605+
[
606+
(
607+
1,
608+
{
609+
"options": "{{ ['test', 'yes', 'no'] }}",
610+
"select_option": [],
611+
"state": "{{ states('select.test_state') }}",
612+
"availability": "{{ is_state('binary_sensor.test_availability', 'on') }}",
613+
},
614+
)
615+
],
616+
)
617+
@pytest.mark.parametrize(
618+
"style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER]
619+
)
620+
@pytest.mark.usefixtures("setup_select")
621+
async def test_availability(hass: HomeAssistant) -> None:
622+
"""Test configuration with optimistic state."""
623+
624+
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on")
625+
hass.states.async_set(TEST_STATE_ENTITY_ID, "test")
626+
await hass.async_block_till_done()
627+
628+
state = hass.states.get(_TEST_SELECT)
629+
assert state.state == "test"
630+
631+
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off")
632+
await hass.async_block_till_done()
633+
634+
state = hass.states.get(_TEST_SELECT)
635+
assert state.state == STATE_UNAVAILABLE
636+
637+
hass.states.async_set(TEST_STATE_ENTITY_ID, "yes")
638+
await hass.async_block_till_done()
639+
640+
state = hass.states.get(_TEST_SELECT)
641+
assert state.state == STATE_UNAVAILABLE
642+
643+
hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on")
644+
await hass.async_block_till_done()
645+
646+
state = hass.states.get(_TEST_SELECT)
647+
assert state.state == "yes"

0 commit comments

Comments
 (0)