288 lines
8.8 KiB
Python
288 lines
8.8 KiB
Python
"""Component to allow setting text as platforms."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import timedelta
|
|
from enum import StrEnum
|
|
import logging
|
|
import re
|
|
from typing import TYPE_CHECKING, Any, final
|
|
|
|
import voluptuous as vol
|
|
|
|
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,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from functools import cached_property
|
|
else:
|
|
from homeassistant.backports.functools import cached_property
|
|
|
|
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"
|
|
|
|
|
|
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())
|