"""Component to allow setting text as platforms.""" from dataclasses import asdict, dataclass from datetime import timedelta from enum import StrEnum import logging import re from typing import Any, final from propcache.api import cached_property import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv 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 homeassistant.util.hass_dict import HassKey from .const import ( ATTR_MAX, ATTR_MIN, ATTR_PATTERN, ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE, ) _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=30) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) __all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Text entities.""" component = hass.data[DATA_COMPONENT] = 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}" f" 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}" f" 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.""" return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class TextMode(StrEnum): """Modes for text entities.""" PASSWORD = "password" TEXT = "text" class TextEntityDescription(EntityDescription, frozen_or_thawed=True): """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 CACHED_PROPERTIES_WITH_ATTR_ = { "mode", "native_value", "native_min", "native_max", "pattern", } class TextEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Text entity.""" _entity_component_unrecorded_attributes = frozenset( {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} ) 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 @cached_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 @cached_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) @cached_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 @cached_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 @cached_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())