diff --git a/.strict-typing b/.strict-typing index 2427bcdd754..d389d9e1950 100644 --- a/.strict-typing +++ b/.strict-typing @@ -61,6 +61,7 @@ homeassistant.components.recorder.repack homeassistant.components.recorder.statistics homeassistant.components.remote.* homeassistant.components.scene.* +homeassistant.components.select.* homeassistant.components.sensor.* homeassistant.components.slack.* homeassistant.components.sonos.media_player diff --git a/CODEOWNERS b/CODEOWNERS index 3f895d6572d..c19f4cbc721 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -423,6 +423,7 @@ homeassistant/components/scrape/* @fabaff homeassistant/components/screenlogic/* @dieselrabbit homeassistant/components/script/* @home-assistant/core homeassistant/components/search/* @home-assistant/core +homeassistant/components/select/* @home-assistant/core homeassistant/components/sense/* @kbickar homeassistant/components/sensibo/* @andrey-git homeassistant/components/sentry/* @dcramer @frenck diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b32537ae44e..acd98465207 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -20,6 +20,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "lock", "media_player", "number", + "select", "sensor", "switch", "vacuum", diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py new file mode 100644 index 00000000000..dcc0c12a9b4 --- /dev/null +++ b/homeassistant/components/demo/select.py @@ -0,0 +1,80 @@ +"""Demo platform that offers a fake select entity.""" +from __future__ import annotations + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the demo Select entity.""" + async_add_entities( + [ + DemoSelect( + unique_id="speed", + name="Speed", + icon="mdi:speedometer", + device_class="demo__speed", + current_option="ridiculous_speed", + options=[ + "light_speed", + "ridiculous_speed", + "ludicrous_speed", + ], + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSelect(SelectEntity): + """Representation of a demo select entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + icon: str, + device_class: str | None, + current_option: str | None, + options: list[str], + ) -> None: + """Initialize the Demo select entity.""" + self._attr_unique_id = unique_id + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_current_option = current_option + self._attr_icon = icon + self._attr_device_class = device_class + self._attr_options = options + self._attr_device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": name, + } + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + if option not in self.options: + raise ValueError(f"Invalid option for {self.entity_id}: {option}") + + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/demo/strings.select.json b/homeassistant/components/demo/strings.select.json new file mode 100644 index 00000000000..f797ab562bc --- /dev/null +++ b/homeassistant/components/demo/strings.select.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Light Speed", + "ludicrous_speed": "Ludicrous Speed", + "ridiculous_speed": "Ridiculous Speed" + } + } +} diff --git a/homeassistant/components/demo/translations/select.en.json b/homeassistant/components/demo/translations/select.en.json new file mode 100644 index 00000000000..e7f7c67f452 --- /dev/null +++ b/homeassistant/components/demo/translations/select.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Light Speed", + "ludicrous_speed": "Ludicrous Speed", + "ridiculous_speed": "Ridiculous Speed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py new file mode 100644 index 00000000000..4ec8c46ef05 --- /dev/null +++ b/homeassistant/components/select/__init__.py @@ -0,0 +1,98 @@ +"""Component to allow selecting an option from a list as platforms.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any, final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import ATTR_OPTION, ATTR_OPTIONS, DOMAIN, SERVICE_SELECT_OPTION + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Select entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SELECT_OPTION, + {vol.Required(ATTR_OPTION): cv.string}, + "async_select_option", + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class SelectEntity(Entity): + """Representation of a Select entity.""" + + _attr_current_option: str | None + _attr_options: list[str] + _attr_state: None = None + + @property + def capability_attributes(self) -> dict[str, Any]: + """Return capability attributes.""" + return { + ATTR_OPTIONS: self.options, + } + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.current_option is None or self.current_option not in self.options: + return None + return self.current_option + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self._attr_options + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self._attr_current_option + + def select_option(self, option: str) -> None: + """Change the selected option.""" + raise NotImplementedError() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.hass.async_add_executor_job(self.select_option, option) diff --git a/homeassistant/components/select/const.py b/homeassistant/components/select/const.py new file mode 100644 index 00000000000..41598c2edbc --- /dev/null +++ b/homeassistant/components/select/const.py @@ -0,0 +1,8 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "select" + +ATTR_OPTIONS = "options" +ATTR_OPTION = "option" + +SERVICE_SELECT_OPTION = "select_option" diff --git a/homeassistant/components/select/manifest.json b/homeassistant/components/select/manifest.json new file mode 100644 index 00000000000..86e8b917199 --- /dev/null +++ b/homeassistant/components/select/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "select", + "name": "Select", + "documentation": "https://www.home-assistant.io/integrations/select", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/select/services.yaml b/homeassistant/components/select/services.yaml new file mode 100644 index 00000000000..edf7fb50f00 --- /dev/null +++ b/homeassistant/components/select/services.yaml @@ -0,0 +1,14 @@ +select_option: + name: Select + description: Select an option of an select entity. + target: + entity: + domain: select + fields: + option: + name: Option + description: Option to be selected. + required: true + example: '"Item A"' + selector: + text: diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json new file mode 100644 index 00000000000..8e89a4faeb8 --- /dev/null +++ b/homeassistant/components/select/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Select" +} diff --git a/mypy.ini b/mypy.ini index b21c71d172c..52c48a54035 100644 --- a/mypy.ini +++ b/mypy.ini @@ -682,6 +682,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.select.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sensor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index a8e1858cad3..00110e11fbc 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -91,6 +91,7 @@ NO_IOT_CLASS = [ "scene", "script", "search", + "select", "sensor", "stt", "switch", diff --git a/tests/components/demo/test_select.py b/tests/components/demo/test_select.py new file mode 100644 index 00000000000..628c173da7e --- /dev/null +++ b/tests/components/demo/test_select.py @@ -0,0 +1,73 @@ +"""The tests for the demo select component.""" + +import pytest + +from homeassistant.components.select.const import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_SPEED = "select.speed" + + +@pytest.fixture(autouse=True) +async def setup_demo_select(hass: HomeAssistant) -> None: + """Initialize setup demo select entity.""" + assert await async_setup_component(hass, DOMAIN, {"select": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_SPEED) + assert state + assert state.state == "ridiculous_speed" + assert state.attributes.get(ATTR_OPTIONS) == [ + "light_speed", + "ridiculous_speed", + "ludicrous_speed", + ] + + +async def test_select_option_bad_attr(hass: HomeAssistant) -> None: + """Test selecting a different option with invalid option value.""" + state = hass.states.get(ENTITY_SPEED) + assert state + assert state.state == "ridiculous_speed" + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "slow_speed", ATTR_ENTITY_ID: ENTITY_SPEED}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_SPEED) + assert state + assert state.state == "ridiculous_speed" + + +async def test_select_option(hass: HomeAssistant) -> None: + """Test selecting of a option.""" + state = hass.states.get(ENTITY_SPEED) + assert state + assert state.state == "ridiculous_speed" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "light_speed", ATTR_ENTITY_ID: ENTITY_SPEED}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_SPEED) + assert state + assert state.state == "light_speed" diff --git a/tests/components/select/__init__.py b/tests/components/select/__init__.py new file mode 100644 index 00000000000..1e7aecea908 --- /dev/null +++ b/tests/components/select/__init__.py @@ -0,0 +1 @@ +"""The tests for the Select integration.""" diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py new file mode 100644 index 00000000000..188099164c2 --- /dev/null +++ b/tests/components/select/test_init.py @@ -0,0 +1,28 @@ +"""The tests for the Select component.""" +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant + + +class MockSelectEntity(SelectEntity): + """Mock SelectEntity to use in tests.""" + + _attr_current_option = "option_one" + _attr_options = ["option_one", "option_two", "option_three"] + + +async def test_select(hass: HomeAssistant) -> None: + """Test getting data from the mocked select entity.""" + select = MockSelectEntity() + assert select.current_option == "option_one" + assert select.state == "option_one" + assert select.options == ["option_one", "option_two", "option_three"] + + # Test none selected + select._attr_current_option = None + assert select.current_option is None + assert select.state is None + + # Test none existing selected + select._attr_current_option = "option_four" + assert select.current_option == "option_four" + assert select.state is None