From 742159a6a62e8142a5b84c0cc09e5ad1f95c2ea6 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 23 Jun 2021 17:20:49 +0200 Subject: [PATCH] Add number entity to KNX (#51786) Co-authored-by: Franck Nijhof --- homeassistant/components/knx/__init__.py | 2 + homeassistant/components/knx/const.py | 10 ++- homeassistant/components/knx/number.py | 96 ++++++++++++++++++++++++ homeassistant/components/knx/schema.py | 75 +++++++++++++++++- 4 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/knx/number.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index c30b24475c0..613426aaa8f 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -49,6 +49,7 @@ from .schema import ( FanSchema, LightSchema, NotifySchema, + NumberSchema, SceneSchema, SensorSchema, SwitchSchema, @@ -93,6 +94,7 @@ CONFIG_SCHEMA = vol.Schema( **FanSchema.platform_node(), **LightSchema.platform_node(), **NotifySchema.platform_node(), + **NumberSchema.platform_node(), **SceneSchema.platform_node(), **SensorSchema.platform_node(), **SwitchSchema.platform_node(), diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 74c6045b767..08d431b0cff 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -26,14 +26,15 @@ DOMAIN: Final = "knx" # Address is used for configuration and services by the same functions so the key has to match KNX_ADDRESS: Final = "address" -CONF_KNX_ROUTING: Final = "routing" -CONF_KNX_TUNNELING: Final = "tunneling" -CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" CONF_INVERT: Final = "invert" CONF_KNX_EXPOSE: Final = "expose" +CONF_KNX_INDIVIDUAL_ADDRESS: Final = "individual_address" +CONF_KNX_ROUTING: Final = "routing" +CONF_KNX_TUNNELING: Final = "tunneling" +CONF_RESET_AFTER: Final = "reset_after" +CONF_RESPOND_TO_READ = "respond_to_read" CONF_STATE_ADDRESS: Final = "state_address" CONF_SYNC_STATE: Final = "sync_state" -CONF_RESET_AFTER: Final = "reset_after" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" @@ -56,6 +57,7 @@ class SupportedPlatforms(Enum): FAN = "fan" LIGHT = "light" NOTIFY = "notify" + NUMBER = "number" SCENE = "scene" SENSOR = "sensor" SWITCH = "switch" diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py new file mode 100644 index 00000000000..b438551ebda --- /dev/null +++ b/homeassistant/components/knx/number.py @@ -0,0 +1,96 @@ +"""Support for KNX/IP numeric values.""" +from __future__ import annotations + +from typing import cast + +from xknx import XKNX +from xknx.devices import NumericValue + +from homeassistant.components.number import NumberEntity +from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, DOMAIN, KNX_ADDRESS +from .knx_entity import KnxEntity +from .schema import NumberSchema + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up number entities for KNX platform.""" + if not discovery_info or not discovery_info["platform_config"]: + return + + platform_config = discovery_info["platform_config"] + xknx: XKNX = hass.data[DOMAIN].xknx + + async_add_entities( + KNXNumber(xknx, entity_config) for entity_config in platform_config + ) + + +def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue: + """Return a KNX NumericValue to be used within XKNX.""" + return NumericValue( + xknx, + name=config[CONF_NAME], + group_address=config[KNX_ADDRESS], + group_address_state=config.get(CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + value_type=config[CONF_TYPE], + ) + + +class KNXNumber(KnxEntity, NumberEntity, RestoreEntity): + """Representation of a KNX number.""" + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX number.""" + self._device: NumericValue + super().__init__(_create_numeric_value(xknx, config)) + self._unique_id = f"{self._device.sensor_value.group_address}" + + self._attr_min_value = config.get( + NumberSchema.CONF_MIN, + self._device.sensor_value.dpt_class.value_min, + ) + self._attr_max_value = config.get( + NumberSchema.CONF_MAX, + self._device.sensor_value.dpt_class.value_max, + ) + self._attr_step = config.get( + NumberSchema.CONF_STEP, + self._device.sensor_value.dpt_class.resolution, + ) + self._device.sensor_value.value = max(0, self.min_value) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if not self._device.sensor_value.readable and ( + last_state := await self.async_get_last_state() + ): + if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + self._device.sensor_value.value = float(last_state.state) + + @property + def value(self) -> float: + """Return the entity value to represent the entity state.""" + # self._device.sensor_value.value is set in __init__ so it is never None + return cast(float, self._device.resolve_state()) + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + if value < self.min_value or value > self.max_value: + raise ValueError( + f"Invalid value for {self.entity_id}: {value} " + f"(range {self.min_value} - {self.max_value})" + ) + await self._device.set(value) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 0715b68d575..37730010104 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -2,12 +2,13 @@ from __future__ import annotations from abc import ABC +from collections import OrderedDict from typing import Any, ClassVar import voluptuous as vol from xknx import XKNX from xknx.devices.climate import SetpointShiftMode -from xknx.dpt import DPTBase +from xknx.dpt import DPTBase, DPTNumeric from xknx.exceptions import CouldNotParseAddress from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram.address import IndividualAddress, parse_device_group_address @@ -33,6 +34,7 @@ from .const import ( CONF_KNX_ROUTING, CONF_KNX_TUNNELING, CONF_RESET_AFTER, + CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, CONTROLLER_MODES, @@ -70,6 +72,50 @@ ia_validator = vol.Any( ) +def number_limit_sub_validator(entity_config: OrderedDict) -> OrderedDict: + """Validate a number entity configurations dependent on configured value type.""" + value_type = entity_config[CONF_TYPE] + min_config: float | None = entity_config.get(NumberSchema.CONF_MIN) + max_config: float | None = entity_config.get(NumberSchema.CONF_MAX) + step_config: float | None = entity_config.get(NumberSchema.CONF_STEP) + dpt_class = DPTNumeric.parse_transcoder(value_type) + + if dpt_class is None: + raise vol.Invalid(f"'type: {value_type}' is not a valid numeric sensor type.") + # Inifinity is not supported by Home Assistant frontend so user defined + # config is required if if xknx DPTNumeric subclass defines it as limit. + if min_config is None and dpt_class.value_min == float("-inf"): + raise vol.Invalid(f"'min' key required for value type '{value_type}'") + if min_config is not None and min_config < dpt_class.value_min: + raise vol.Invalid( + f"'min: {min_config}' undercuts possible minimum" + f" of value type '{value_type}': {dpt_class.value_min}" + ) + + if max_config is None and dpt_class.value_max == float("inf"): + raise vol.Invalid(f"'max' key required for value type '{value_type}'") + if max_config is not None and max_config > dpt_class.value_max: + raise vol.Invalid( + f"'max: {max_config}' exceeds possible maximum" + f" of value type '{value_type}': {dpt_class.value_max}" + ) + + if step_config is not None and step_config < dpt_class.resolution: + raise vol.Invalid( + f"'step: {step_config}' undercuts possible minimum step" + f" of value type '{value_type}': {dpt_class.resolution}" + ) + + return entity_config + + +def numeric_type_validator(value: Any) -> str | int: + """Validate that value is parsable as numeric sensor type.""" + if isinstance(value, (str, int)) and DPTNumeric.parse_transcoder(value) is not None: + return value + raise vol.Invalid(f"value '{value}' is not a valid numeric sensor type.") + + def sensor_type_validator(value: Any) -> str | int: """Validate that value is parsable as sensor type.""" if isinstance(value, (str, int)) and DPTBase.parse_transcoder(value) is not None: @@ -525,6 +571,33 @@ class NotifySchema(KNXPlatformSchema): ) +class NumberSchema(KNXPlatformSchema): + """Voluptuous schema for KNX numbers.""" + + PLATFORM_NAME = SupportedPlatforms.NUMBER.value + + CONF_MAX = "max" + CONF_MIN = "min" + CONF_STEP = "step" + DEFAULT_NAME = "KNX Number" + + ENTITY_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, + vol.Required(CONF_TYPE): numeric_type_validator, + vol.Required(KNX_ADDRESS): ga_list_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_MAX): vol.Coerce(float), + vol.Optional(CONF_MIN): vol.Coerce(float), + vol.Optional(CONF_STEP): cv.positive_float, + } + ), + number_limit_sub_validator, + ) + + class SceneSchema(KNXPlatformSchema): """Voluptuous schema for KNX scenes."""