diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 2049de7ab27..81099e20418 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -1,20 +1,27 @@ """Support to enter a value into a text box.""" import logging +import typing import voluptuous as vol from homeassistant.const import ( + ATTR_EDITABLE, ATTR_MODE, - ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, + CONF_ID, CONF_MODE, CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, SERVICE_RELOAD, ) +from homeassistant.core import callback +from homeassistant.helpers import collection, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceCallType _LOGGER = logging.getLogger(__name__) @@ -26,16 +33,41 @@ CONF_MIN = "min" CONF_MIN_VALUE = 0 CONF_MAX = "max" CONF_MAX_VALUE = 100 +CONF_PATTERN = "pattern" +CONF_VALUE = "value" MODE_TEXT = "text" MODE_PASSWORD = "password" -ATTR_VALUE = "value" +ATTR_VALUE = CONF_VALUE ATTR_MIN = "min" ATTR_MAX = "max" -ATTR_PATTERN = "pattern" +ATTR_PATTERN = CONF_PATTERN SERVICE_SET_VALUE = "set_value" +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CREATE_FIELDS = { + vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ""): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_PATTERN): cv.string, + vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In([MODE_TEXT, MODE_PASSWORD]), +} +UPDATE_FIELDS = { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN): vol.Coerce(int), + vol.Optional(CONF_MAX): vol.Coerce(int), + vol.Optional(CONF_INITIAL): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_PATTERN): cv.string, + vol.Optional(CONF_MODE): vol.In([MODE_TEXT, MODE_PASSWORD]), +} def _cv_input_text(cfg): @@ -65,8 +97,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), vol.Optional(CONF_INITIAL, ""): cv.string, vol.Optional(CONF_ICON): cv.icon, - vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(ATTR_PATTERN): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_PATTERN): cv.string, vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In( [MODE_TEXT, MODE_PASSWORD] ), @@ -83,20 +115,57 @@ CONFIG_SCHEMA = vol.Schema( RELOAD_SERVICE_SCHEMA = vol.Schema({}) -async def async_setup(hass, config): - """Set up an input text box.""" +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up an input text.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + id_manager = collection.IDManager() - entities = await _async_process_config(config) + yaml_collection = collection.YamlCollection( + logging.getLogger(f"{__name__}.yaml_collection"), id_manager + ) + collection.attach_entity_component_collection( + component, yaml_collection, InputText.from_yaml + ) - async def reload_service_handler(service_call): - """Remove all entities and load new ones from config.""" - conf = await component.async_prepare_reload() - if conf is None: + storage_collection = InputTextStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY), + logging.getLogger(f"{__name__}.storage_collection"), + id_manager, + ) + collection.attach_entity_component_collection( + component, storage_collection, InputText + ) + + await yaml_collection.async_load( + [{CONF_ID: id_, **(conf or {})} for id_, conf in config[DOMAIN].items()] + ) + await storage_collection.async_load() + + collection.StorageCollectionWebsocket( + storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS + ).async_setup(hass) + + async def _collection_changed( + change_type: str, item_id: str, config: typing.Optional[typing.Dict] + ) -> None: + """Handle a collection change: clean up entity registry on removals.""" + if change_type != collection.CHANGE_REMOVED: return - new_entities = await _async_process_config(conf) - if new_entities: - await component.async_add_entities(new_entities) + + ent_reg = await entity_registry.async_get_registry(hass) + ent_reg.async_remove(ent_reg.async_get_entity_id(DOMAIN, DOMAIN, item_id)) + + yaml_collection.async_add_listener(_collection_changed) + storage_collection.async_add_listener(_collection_changed) + + async def reload_service_handler(service_call: ServiceCallType) -> None: + """Reload yaml entities.""" + conf = await component.async_prepare_reload(skip_reset=True) + if conf is None: + conf = {DOMAIN: {}} + await yaml_collection.async_load( + [{CONF_ID: id_, **(cfg or {})} for id_, cfg in conf[DOMAIN].items()] + ) homeassistant.helpers.service.async_register_admin_service( hass, @@ -110,52 +179,53 @@ async def async_setup(hass, config): SERVICE_SET_VALUE, {vol.Required(ATTR_VALUE): cv.string}, "async_set_value" ) - if entities: - await component.async_add_entities(entities) return True -async def _async_process_config(config): - """Process config and create list of entities.""" - entities = [] +class InputTextStorageCollection(collection.StorageCollection): + """Input storage based collection.""" - for object_id, cfg in config[DOMAIN].items(): - if cfg is None: - cfg = {} - name = cfg.get(CONF_NAME) - minimum = cfg.get(CONF_MIN, CONF_MIN_VALUE) - maximum = cfg.get(CONF_MAX, CONF_MAX_VALUE) - initial = cfg.get(CONF_INITIAL) - icon = cfg.get(CONF_ICON) - unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) - pattern = cfg.get(ATTR_PATTERN) - mode = cfg.get(CONF_MODE) + CREATE_SCHEMA = vol.Schema(vol.All(CREATE_FIELDS, _cv_input_text)) + UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS) - entities.append( - InputText( - object_id, name, initial, minimum, maximum, icon, unit, pattern, mode - ) - ) + async def _process_create_data(self, data: typing.Dict) -> typing.Dict: + """Validate the config is valid.""" + return self.CREATE_SCHEMA(data) - return entities + @callback + def _get_suggested_id(self, info: typing.Dict) -> str: + """Suggest an ID based on the config.""" + return info[CONF_NAME] + + async def _update_data(self, data: dict, update_data: typing.Dict) -> typing.Dict: + """Return a new updated data object.""" + update_data = self.UPDATE_SCHEMA(update_data) + return _cv_input_text({**data, **update_data}) class InputText(RestoreEntity): """Represent a text box.""" - def __init__( - self, object_id, name, initial, minimum, maximum, icon, unit, pattern, mode - ): + def __init__(self, config: typing.Dict): """Initialize a text input.""" - self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self._name = name - self._current_value = initial - self._minimum = minimum - self._maximum = maximum - self._icon = icon - self._unit = unit - self._pattern = pattern - self._mode = mode + self._config = config + self.editable = True + self._current_value = config.get(CONF_INITIAL) + + @classmethod + def from_yaml(cls, config: typing.Dict) -> "InputText": + """Return entity instance initialized from yaml storage.""" + # set defaults for empty config + config = { + CONF_MAX: CONF_MAX_VALUE, + CONF_MIN: CONF_MIN_VALUE, + CONF_MODE: MODE_TEXT, + **config, + } + input_text = cls(config) + input_text.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + input_text.editable = False + return input_text @property def should_poll(self): @@ -165,12 +235,22 @@ class InputText(RestoreEntity): @property def name(self): """Return the name of the text input entity.""" - return self._name + return self._config.get(CONF_NAME) @property def icon(self): """Return the icon to be used for this entity.""" - return self._icon + return self._config.get(CONF_ICON) + + @property + def _maximum(self) -> int: + """Return max len of the text.""" + return self._config[CONF_MAX] + + @property + def _minimum(self) -> int: + """Return min len of the text.""" + return self._config[CONF_MIN] @property def state(self): @@ -180,16 +260,22 @@ class InputText(RestoreEntity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._unit + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + + @property + def unique_id(self) -> typing.Optional[str]: + """Return unique id for the entity.""" + return self._config[CONF_ID] @property def state_attributes(self): """Return the state attributes.""" return { + ATTR_EDITABLE: self.editable, ATTR_MIN: self._minimum, ATTR_MAX: self._maximum, - ATTR_PATTERN: self._pattern, - ATTR_MODE: self._mode, + ATTR_PATTERN: self._config.get(CONF_PATTERN), + ATTR_MODE: self._config[CONF_MODE], } async def async_added_to_hass(self): @@ -216,4 +302,9 @@ class InputText(RestoreEntity): ) return self._current_value = value - await self.async_update_ha_state() + self.async_write_ha_state() + + async def async_update_config(self, config: typing.Dict) -> None: + """Handle when the config is updated.""" + self._config = config + self.async_write_ha_state() diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 8835128d672..d6478a5472f 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -4,15 +4,71 @@ from unittest.mock import patch import pytest -from homeassistant.components.input_text import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_RELOAD +from homeassistant.components.input_text import ( + ATTR_MAX, + ATTR_MIN, + ATTR_MODE, + ATTR_VALUE, + CONF_INITIAL, + CONF_MAX_VALUE, + CONF_MIN_VALUE, + DOMAIN, + MODE_TEXT, + SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_EDITABLE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_NAME, + SERVICE_RELOAD, +) from homeassistant.core import Context, CoreState, State from homeassistant.exceptions import Unauthorized +from homeassistant.helpers import entity_registry from homeassistant.loader import bind_hass from homeassistant.setup import async_setup_component from tests.common import mock_restore_cache +TEST_VAL_MIN = 2 +TEST_VAL_MAX = 22 + + +@pytest.fixture +def storage_setup(hass, hass_storage): + """Storage setup.""" + + async def _storage(items=None, config=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": { + "items": [ + { + "id": "from_storage", + "name": "from storage", + "initial": "loaded from storage", + ATTR_MAX: TEST_VAL_MAX, + ATTR_MIN: TEST_VAL_MIN, + ATTR_MODE: MODE_TEXT, + } + ] + }, + } + else: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": {"items": items}, + } + if config is None: + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + @bind_hass def set_value(hass, entity_id, value): @@ -109,7 +165,7 @@ async def test_restore_state(hass): hass.state = CoreState.starting assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {"b1": None, "b2": {"min": 0, "max": 10}}}, + hass, DOMAIN, {DOMAIN: {"b1": None, "b2": {"min": 0, "max": 10}}} ) state = hass.states.get("input_text.b1") @@ -192,6 +248,11 @@ async def test_config_none(hass): assert state assert str(state.state) == "unknown" + # with empty config we still should have the defaults + assert state.attributes[ATTR_MODE] == MODE_TEXT + assert state.attributes[ATTR_MAX] == CONF_MAX_VALUE + assert state.attributes[ATTR_MIN] == CONF_MIN_VALUE + async def test_reload(hass, hass_admin_user, hass_read_only_user): """Test reload service.""" @@ -214,14 +275,16 @@ async def test_reload(hass, hass_admin_user, hass_read_only_user): assert state_3 is None assert "test 1" == state_1.state assert "test 2" == state_2.state + assert state_1.attributes[ATTR_MIN] == 0 + assert state_2.attributes[ATTR_MAX] == 100 with patch( "homeassistant.config.load_yaml_config_file", autospec=True, return_value={ DOMAIN: { - "test_2": {"initial": "test reloaded"}, - "test_3": {"initial": "test 3"}, + "test_2": {"initial": "test reloaded", ATTR_MIN: 12}, + "test_3": {"initial": "test 3", ATTR_MAX: 21}, } }, ): @@ -250,5 +313,174 @@ async def test_reload(hass, hass_admin_user, hass_read_only_user): assert state_1 is None assert state_2 is not None assert state_3 is not None - assert "test reloaded" == state_2.state - assert "test 3" == state_3.state + assert state_2.attributes[ATTR_MIN] == 12 + assert state_3.attributes[ATTR_MAX] == 21 + + +async def test_load_from_storage(hass, storage_setup): + """Test set up from storage.""" + assert await storage_setup() + state = hass.states.get(f"{DOMAIN}.from_storage") + assert state.state == "loaded from storage" + assert state.attributes.get(ATTR_EDITABLE) + assert state.attributes[ATTR_MAX] == TEST_VAL_MAX + assert state.attributes[ATTR_MIN] == TEST_VAL_MIN + + +async def test_editable_state_attribute(hass, storage_setup): + """Test editable attribute.""" + assert await storage_setup( + config={ + DOMAIN: { + "from_yaml": { + "initial": "yaml initial value", + ATTR_MODE: MODE_TEXT, + ATTR_MAX: 33, + ATTR_MIN: 3, + ATTR_NAME: "yaml friendly name", + } + } + } + ) + + state = hass.states.get(f"{DOMAIN}.from_storage") + assert state.state == "loaded from storage" + assert state.attributes.get(ATTR_EDITABLE) + assert state.attributes[ATTR_MAX] == TEST_VAL_MAX + assert state.attributes[ATTR_MIN] == TEST_VAL_MIN + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state.state == "yaml initial value" + assert not state.attributes[ATTR_EDITABLE] + assert state.attributes[ATTR_MAX] == 33 + assert state.attributes[ATTR_MIN] == 3 + + +async def test_ws_list(hass, hass_ws_client, storage_setup): + """Test listing via WS.""" + assert await storage_setup( + config={ + DOMAIN: { + "from_yaml": { + "initial": "yaml initial value", + ATTR_MODE: MODE_TEXT, + ATTR_MAX: 33, + ATTR_MIN: 3, + ATTR_NAME: "yaml friendly name", + } + } + } + ) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": f"{DOMAIN}/list"}) + resp = await client.receive_json() + assert resp["success"] + + storage_ent = "from_storage" + yaml_ent = "from_yaml" + result = {item["id"]: item for item in resp["result"]} + + assert len(result) == 1 + assert storage_ent in result + assert yaml_ent not in result + assert result[storage_ent][ATTR_NAME] == "from storage" + + +async def test_ws_delete(hass, hass_ws_client, storage_setup): + """Test WS delete cleans up entity registry.""" + assert await storage_setup() + + input_id = "from_storage" + input_entity_id = f"{DOMAIN}.{input_id}" + ent_reg = await entity_registry.async_get_registry(hass) + + state = hass.states.get(input_entity_id) + assert state is not None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 6, "type": f"{DOMAIN}/delete", f"{DOMAIN}_id": f"{input_id}"} + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert state is None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + + +async def test_update(hass, hass_ws_client, storage_setup): + """Test updating min/max updates the state.""" + + assert await storage_setup() + + input_id = "from_storage" + input_entity_id = f"{DOMAIN}.{input_id}" + ent_reg = await entity_registry.async_get_registry(hass) + + state = hass.states.get(input_entity_id) + assert state.attributes[ATTR_FRIENDLY_NAME] == "from storage" + assert state.attributes[ATTR_MODE] == MODE_TEXT + assert state.state == "loaded from storage" + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 6, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": f"{input_id}", + ATTR_NAME: "even newer name", + CONF_INITIAL: "newer option", + ATTR_MIN: 6, + ATTR_MODE: "password", + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert state.state == "loaded from storage" + assert state.attributes[ATTR_FRIENDLY_NAME] == "even newer name" + assert state.attributes[ATTR_MODE] == "password" + assert state.attributes[ATTR_MIN] == 6 + assert state.attributes[ATTR_MAX] == TEST_VAL_MAX + + +async def test_ws_create(hass, hass_ws_client, storage_setup): + """Test create WS.""" + assert await storage_setup(items=[]) + + input_id = "new_input" + input_entity_id = f"{DOMAIN}.{input_id}" + ent_reg = await entity_registry.async_get_registry(hass) + + state = hass.states.get(input_entity_id) + assert state is None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 6, + "type": f"{DOMAIN}/create", + "name": "New Input", + "initial": "even newer option", + ATTR_MAX: 44, + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert state.state == "even newer option" + assert state.attributes[ATTR_FRIENDLY_NAME] == "New Input" + assert state.attributes[ATTR_EDITABLE] + assert state.attributes[ATTR_MAX] == 44 + assert state.attributes[ATTR_MIN] == 0