diff --git a/CODEOWNERS b/CODEOWNERS index 3646c36422d..46e66291eeb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1156,6 +1156,8 @@ build.json @home-assistant/supervisor /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/text/ @home-assistant/core +/tests/components/text/ @home-assistant/core /homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index dd14d9f7b2a..ae6912fa0f9 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -52,6 +52,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.TEXT, Platform.UPDATE, Platform.VACUUM, Platform.WATER_HEATER, diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py new file mode 100644 index 00000000000..efce1af5c37 --- /dev/null +++ b/homeassistant/components/demo/text.py @@ -0,0 +1,101 @@ +"""Demo platform that offers a fake text entity.""" +from __future__ import annotations + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +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, +) -> None: + """Set up the demo Text entity.""" + async_add_entities( + [ + DemoText( + unique_id="text", + name="Text", + icon=None, + native_value="Hello world", + ), + DemoText( + unique_id="password", + name="Password", + icon="mdi:text", + native_value="Hello world", + mode=TextMode.PASSWORD, + ), + DemoText( + unique_id="text_1_to_5_char", + name="Text with 1 to 5 characters", + icon="mdi:text", + native_value="Hello", + native_min=1, + native_max=5, + ), + DemoText( + unique_id="text_lowercase", + name="Text with only lower case characters", + icon="mdi:text", + native_value="world", + pattern=r"[a-z]+", + ), + ] + ) + + +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 DemoText(TextEntity): + """Representation of a demo text entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + icon: str | None, + native_value: str | None, + mode: TextMode = TextMode.TEXT, + native_max: int | None = None, + native_min: int | None = None, + pattern: str | None = None, + ) -> None: + """Initialize the Demo text entity.""" + self._attr_unique_id = unique_id + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = native_value + self._attr_icon = icon + self._attr_mode = mode + if native_max is not None: + self._attr_native_max = native_max + if native_min is not None: + self._attr_native_min = native_min + if pattern is not None: + self._attr_pattern = pattern + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) + + async def async_set_value(self, value: str) -> None: + """Update the value.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py new file mode 100644 index 00000000000..35c7d5d94ef --- /dev/null +++ b/homeassistant/components/text/__init__.py @@ -0,0 +1,224 @@ +"""Component to allow setting text as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +import re +from typing import Any, final + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.core import HomeAssistant, ServiceCall +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, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_MAX, + ATTR_MIN, + ATTR_MODE, + ATTR_PATTERN, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_VALUE, +) + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Text entities.""" + component = hass.data[DOMAIN] = EntityComponent[TextEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, + {vol.Required(ATTR_VALUE): cv.string}, + _async_set_value, + ) + + return True + + +async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new value.""" + value = service_call.data[ATTR_VALUE] + if len(value) < entity.min: + raise ValueError( + f"Value {value} for {entity.name} is too short (minimum length {entity.min})" + ) + if len(value) > entity.max: + raise ValueError( + f"Value {value} for {entity.name} is too long (maximum length {entity.max})" + ) + if entity.pattern_cmp and not entity.pattern_cmp.match(value): + raise ValueError( + f"Value {value} for {entity.name} doesn't match pattern {entity.pattern}" + ) + await entity.async_set_value(value) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TextEntity] = 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[TextEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class TextMode(StrEnum): + """Modes for text entities.""" + + PASSWORD = "password" + TEXT = "text" + + +@dataclass +class TextEntityDescription(EntityDescription): + """A class that describes text entities.""" + + native_min: int = 0 + native_max: int = MAX_LENGTH_STATE_STATE + mode: TextMode = TextMode.TEXT + pattern: str | None = None + + +class TextEntity(Entity): + """Representation of a Text entity.""" + + entity_description: TextEntityDescription + _attr_mode: TextMode + _attr_native_value: str | None + _attr_native_min: int + _attr_native_max: int + _attr_pattern: str | None + _attr_state: None = None + __pattern_cmp: re.Pattern | None = None + + @property + def capability_attributes(self) -> dict[str, Any]: + """Return capability attributes.""" + return { + ATTR_MODE: self.mode, + ATTR_MIN: self.min, + ATTR_MAX: self.max, + ATTR_PATTERN: self.pattern, + } + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + if len(self.native_value) < self.min: + raise ValueError( + f"Entity {self.entity_id} provides state {self.native_value} which is " + f"too short (minimum length {self.min})" + ) + if len(self.native_value) > self.max: + raise ValueError( + f"Entity {self.entity_id} provides state {self.native_value} which is " + f"too long (maximum length {self.max})" + ) + if self.pattern_cmp and not self.pattern_cmp.match(self.native_value): + raise ValueError( + f"Entity {self.entity_id} provides state {self.native_value} which " + f"does not match expected pattern {self.pattern}" + ) + return self.native_value + + @property + def mode(self) -> TextMode: + """Return the mode of the entity.""" + if hasattr(self, "_attr_mode"): + return self._attr_mode + if hasattr(self, "entity_description"): + return self.entity_description.mode + return TextMode.TEXT + + @property + def native_min(self) -> int: + """Return the minimum length of the value.""" + if hasattr(self, "_attr_native_min"): + return self._attr_native_min + if hasattr(self, "entity_description"): + return self.entity_description.native_min + return 0 + + @property + @final + def min(self) -> int: + """Return the minimum length of the value.""" + return max(self.native_min, 0) + + @property + def native_max(self) -> int: + """Return the maximum length of the value.""" + if hasattr(self, "_attr_native_max"): + return self._attr_native_max + if hasattr(self, "entity_description"): + return self.entity_description.native_max + return MAX_LENGTH_STATE_STATE + + @property + @final + def max(self) -> int: + """Return the maximum length of the value.""" + return min(self.native_max, MAX_LENGTH_STATE_STATE) + + @property + @final + def pattern_cmp(self) -> re.Pattern | None: + """Return a compiled pattern.""" + if self.pattern is None: + self.__pattern_cmp = None + return None + if not self.__pattern_cmp or self.pattern != self.__pattern_cmp.pattern: + self.__pattern_cmp = re.compile(self.pattern) + return self.__pattern_cmp + + @property + def pattern(self) -> str | None: + """Return the regex pattern that the value must match.""" + if hasattr(self, "_attr_pattern"): + return self._attr_pattern + if hasattr(self, "entity_description"): + return self.entity_description.pattern + return None + + @property + def native_value(self) -> str | None: + """Return the value reported by the text.""" + return self._attr_native_value + + def set_value(self, value: str) -> None: + """Change the value.""" + raise NotImplementedError() + + async def async_set_value(self, value: str) -> None: + """Change the value.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/text/const.py b/homeassistant/components/text/const.py new file mode 100644 index 00000000000..3670c30120b --- /dev/null +++ b/homeassistant/components/text/const.py @@ -0,0 +1,11 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "text" + +ATTR_MAX = "max" +ATTR_MIN = "min" +ATTR_MODE = "mode" +ATTR_PATTERN = "pattern" +ATTR_VALUE = "value" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/text/manifest.json b/homeassistant/components/text/manifest.json new file mode 100644 index 00000000000..3e45499302a --- /dev/null +++ b/homeassistant/components/text/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "text", + "name": "Text", + "documentation": "https://www.home-assistant.io/integrations/text", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal", + "integration_type": "entity" +} diff --git a/homeassistant/components/text/recorder.py b/homeassistant/components/text/recorder.py new file mode 100644 index 00000000000..09642eb3079 --- /dev/null +++ b/homeassistant/components/text/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude static attributes from being recorded in the database.""" + return {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} diff --git a/homeassistant/components/text/services.yaml b/homeassistant/components/text/services.yaml new file mode 100644 index 00000000000..00dd0ecafd2 --- /dev/null +++ b/homeassistant/components/text/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set value + description: Set value of a text entity. + target: + entity: + domain: text + fields: + value: + name: Value + description: Value to set. + required: true + example: "Hello world!" + selector: + text: diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json new file mode 100644 index 00000000000..8d4d14669a0 --- /dev/null +++ b/homeassistant/components/text/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Text" +} diff --git a/homeassistant/const.py b/homeassistant/const.py index e01a9775917..22b7ea5ab50 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -49,6 +49,7 @@ class Platform(StrEnum): SIREN = "siren" STT = "stt" SWITCH = "switch" + TEXT = "text" TTS = "tts" VACUUM = "vacuum" UPDATE = "update" diff --git a/tests/components/demo/test_text.py b/tests/components/demo/test_text.py new file mode 100644 index 00000000000..df93531bd1a --- /dev/null +++ b/tests/components/demo/test_text.py @@ -0,0 +1,44 @@ +"""The tests for the demo text component.""" +import pytest + +from homeassistant.components.text import ( + ATTR_MAX, + ATTR_MIN, + ATTR_PATTERN, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, MAX_LENGTH_STATE_STATE +from homeassistant.setup import async_setup_component + +ENTITY_TEXT = "text.text" + + +@pytest.fixture(autouse=True) +async def setup_demo_text(hass): + """Initialize setup demo text.""" + assert await async_setup_component(hass, DOMAIN, {"text": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass): + """Test the initial parameters.""" + state = hass.states.get(ENTITY_TEXT) + assert state.state == "Hello world" + assert state.attributes[ATTR_MIN] == 0 + assert state.attributes[ATTR_MAX] == MAX_LENGTH_STATE_STATE + assert state.attributes[ATTR_PATTERN] is None + assert state.attributes[ATTR_MODE] == "text" + + +async def test_set_value(hass): + """Test set value service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_TEXT, ATTR_VALUE: "new"}, + blocking=True, + ) + state = hass.states.get(ENTITY_TEXT) + assert state.state == "new" diff --git a/tests/components/text/__init__.py b/tests/components/text/__init__.py new file mode 100644 index 00000000000..e22fa1d34a1 --- /dev/null +++ b/tests/components/text/__init__.py @@ -0,0 +1 @@ +"""Tests for the text component.""" diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py new file mode 100644 index 00000000000..1bbb3e1e1b0 --- /dev/null +++ b/tests/components/text/test_init.py @@ -0,0 +1,104 @@ +"""The tests for the text component.""" +import pytest + +from homeassistant.components.text import ( + ATTR_MAX, + ATTR_MIN, + ATTR_MODE, + ATTR_PATTERN, + ATTR_VALUE, + DOMAIN, + SERVICE_SET_VALUE, + TextEntity, + TextMode, + _async_set_value, +) +from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.core import ServiceCall + + +class MockTextEntity(TextEntity): + """Mock text device to use in tests.""" + + def __init__( + self, native_value="test", native_min=None, native_max=None, pattern=None + ): + """Initialize mock text entity.""" + self._attr_native_value = native_value + if native_min is not None: + self._attr_native_min = native_min + if native_max is not None: + self._attr_native_max = native_max + if pattern is not None: + self._attr_pattern = pattern + + async def async_set_value(self, value: str) -> None: + """Set the value of the text.""" + self._attr_native_value = value + + +async def test_text_default(hass): + """Test text entity with defaults.""" + text = MockTextEntity() + text.hass = hass + + assert text.capability_attributes == { + ATTR_MIN: 0, + ATTR_MAX: MAX_LENGTH_STATE_STATE, + ATTR_MODE: TextMode.TEXT, + ATTR_PATTERN: None, + } + assert text.pattern is None + assert text.state == "test" + + +async def test_text_new_min_max_pattern(hass): + """Test text entity with new min, max, and pattern.""" + text = MockTextEntity(native_min=-1, native_max=500, pattern=r"[a-z]") + text.hass = hass + + assert text.capability_attributes == { + ATTR_MIN: 0, + ATTR_MAX: MAX_LENGTH_STATE_STATE, + ATTR_MODE: TextMode.TEXT, + ATTR_PATTERN: r"[a-z]", + } + + +async def test_text_set_value(hass): + """Test text entity with set_value service.""" + text = MockTextEntity(native_min=1, native_max=5, pattern=r"[a-z]") + text.hass = hass + + with pytest.raises(ValueError): + await _async_set_value( + text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: ""}) + ) + + with pytest.raises(ValueError): + await _async_set_value( + text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "hello world!"}) + ) + + with pytest.raises(ValueError): + await _async_set_value( + text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "HELLO"}) + ) + + await _async_set_value( + text, ServiceCall(DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: "test2"}) + ) + + assert text.state == "test2" + + +async def test_text_value_outside_bounds(hass): + """Test text entity with value that is outside min and max.""" + with pytest.raises(ValueError): + MockTextEntity( + "hello world", native_min=2, native_max=5, pattern=r"[a-z]" + ).state + with pytest.raises(ValueError): + MockTextEntity( + "hello world", native_min=15, native_max=20, pattern=r"[a-z]" + ).state diff --git a/tests/components/text/test_recorder.py b/tests/components/text/test_recorder.py new file mode 100644 index 00000000000..6ad0df13ffa --- /dev/null +++ b/tests/components/text/test_recorder.py @@ -0,0 +1,41 @@ +"""The tests for text recorder.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components import text +from homeassistant.components.recorder.db_schema import StateAttributes, States +from homeassistant.components.recorder.util import session_scope +from homeassistant.components.text import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.core import State +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.recorder.common import async_wait_recording_done + + +async def test_exclude_attributes(recorder_mock, hass): + """Test siren registered attributes to be excluded.""" + await async_setup_component(hass, text.DOMAIN, {text.DOMAIN: {"platform": "demo"}}) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + def _fetch_states() -> list[State]: + with session_scope(hass=hass) as session: + native_states = [] + for db_state, db_state_attributes in session.query(States, StateAttributes): + state = db_state.to_native() + state.attributes = db_state_attributes.to_native() + native_states.append(state) + return native_states + + states: list[State] = await hass.async_add_executor_job(_fetch_states) + assert len(states) > 1 + for state in states: + for attr in (ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN): + assert attr not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes