"""Component to allow setting text as platforms.""" from __future__ import annotations from dataclasses import asdict, 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.restore_state import ExtraStoredData, RestoreEntity 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.entity_id} is too short (minimum length" f" {entity.min})" ) if len(value) > entity.max: raise ValueError( f"Value {value} for {entity.entity_id} 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.entity_id} 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) @dataclass class TextExtraStoredData(ExtraStoredData): """Object to hold extra stored data.""" native_value: str | None native_min: int native_max: int def as_dict(self) -> dict[str, Any]: """Return a dict representation of the text data.""" return asdict(self) @classmethod def from_dict(cls, restored: dict[str, Any]) -> TextExtraStoredData | None: """Initialize a stored text state from a dict.""" try: return cls( restored["native_value"], restored["native_min"], restored["native_max"], ) except KeyError: return None class RestoreText(TextEntity, RestoreEntity): """Mixin class for restoring previous text state.""" @property def extra_restore_state_data(self) -> TextExtraStoredData: """Return text specific state data to be restored.""" return TextExtraStoredData( self.native_value, self.native_min, self.native_max, ) async def async_get_last_text_data(self) -> TextExtraStoredData | None: """Restore attributes.""" if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: return None return TextExtraStoredData.from_dict(restored_last_extra_data.as_dict())