From 323c3faad15c1ff8adc2255a4a2f2e220527a8c9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Sep 2026 20:03:38 +0200 Subject: [PATCH] Don't blow up in select services when there are no options (#182313) --- homeassistant/components/select/__init__.py | 20 +++++-- homeassistant/components/select/strings.json | 3 + tests/components/select/test_init.py | 63 ++++++++++++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 28f58a2a9948..d502a27c93cb 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -167,6 +167,18 @@ class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the selected entity option to represent the entity state.""" return self._attr_current_option + @final + @callback + def _options_or_raise(self) -> list[str]: + """Return the options, raise ServiceValidationError if there are none.""" + if not (options := self.options): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_options", + translation_placeholders={"entity_id": self.entity_id}, + ) + return options + @final @callback def _valid_option_or_raise(self, option: str) -> None: @@ -235,9 +247,9 @@ class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Offset current index.""" current_index = 0 current_option = self.current_option - options = self.options - if current_option is not None and current_option in self.options: - current_index = self.options.index(current_option) + options = self._options_or_raise() + if current_option is not None and current_option in options: + current_index = options.index(current_option) new_index = current_index + offset if cycle: @@ -252,6 +264,6 @@ class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @final async def _async_select_index(self, idx: int) -> None: """Select new option by index.""" - options = self.options + options = self._options_or_raise() new_index = idx % len(options) await self.async_select_option(options[new_index]) diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 03a05f75bd93..4f5b877d8c72 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -50,6 +50,9 @@ } }, "exceptions": { + "no_options": { + "message": "Entity {entity_id} has no options to select from." + }, "not_valid_option": { "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." } diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index a5be7921fcdd..cf510165f411 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -31,6 +31,13 @@ class MockSelectEntity(SelectEntity): _attr_options = ["option_one", "option_two", "option_three"] +class MockSelectEntityWithoutOptions(MockSelectEntity): + """Mock SelectEntity without any options to use in tests.""" + + _attr_name = "select without options" + _attr_options: list[str] = [] + + async def test_select(hass: HomeAssistant) -> None: """Test getting data from the mocked select entity.""" select = MockSelectEntity() @@ -202,3 +209,59 @@ async def test_custom_integration_and_validation( blocking=True, ) assert hass.states.get("select.select_2").state == "option 3" + + +@pytest.mark.parametrize( + ("service", "service_data"), + [ + pytest.param(SERVICE_SELECT_FIRST, {}, id="first"), + pytest.param(SERVICE_SELECT_LAST, {}, id="last"), + pytest.param(SERVICE_SELECT_NEXT, {}, id="next_cycle"), + pytest.param(SERVICE_SELECT_NEXT, {ATTR_CYCLE: False}, id="next_no_cycle"), + pytest.param(SERVICE_SELECT_PREVIOUS, {}, id="previous_cycle"), + pytest.param( + SERVICE_SELECT_PREVIOUS, {ATTR_CYCLE: False}, id="previous_no_cycle" + ), + ], +) +@pytest.mark.parametrize( + "current_option", + [None, "option_one"], + ids=["without_current_option", "with_stale_current_option"], +) +async def test_navigation_services_without_options( + hass: HomeAssistant, + service: str, + service_data: dict[str, bool], + current_option: str | None, +) -> None: + """Test the navigation actions on an entity which has no options.""" + entity = MockSelectEntityWithoutOptions() + entity._attr_current_option = current_option + entity.select_option = MagicMock() + setup_test_component_platform(hass, DOMAIN, [entity]) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert hass.states.get("select.select_without_options").state == STATE_UNKNOWN + + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: "select.select_without_options"} | service_data, + blocking=True, + ) + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "no_options" + assert exc.value.translation_placeholders == { + "entity_id": "select.select_without_options" + } + assert ( + str(exc.value) + == "Entity select.select_without_options has no options to select from" + ) + + entity.select_option.assert_not_called() + assert hass.states.get("select.select_without_options").state == STATE_UNKNOWN