diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 768de0932e7..8c27353e7f0 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -168,6 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = { Platform.FAN, Platform.DATETIME, Platform.LIGHT, + Platform.NUMBER, Platform.SCENE, Platform.SENSOR, Platform.SWITCH, @@ -231,6 +232,14 @@ class FanConf: MAX_STEP: Final = "max_step" +class NumberConf: + """Common config keys for number.""" + + MAX: Final = "max" + MIN: Final = "min" + STEP: Final = "step" + + class SceneConf: """Common config keys for scene.""" diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 30efb5e01ee..645715dc6aa 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -4,28 +4,43 @@ from __future__ import annotations from typing import cast -from xknx import XKNX from xknx.devices import NumericValue from homeassistant import config_entries -from homeassistant.components.number import RestoreNumber +from homeassistant.components.number import NumberDeviceClass, NumberMode, RestoreNumber from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_MODE, CONF_NAME, CONF_TYPE, + CONF_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType +from homeassistant.util.enum import try_parse_enum -from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DOMAIN, + KNX_ADDRESS, + KNX_MODULE_KEY, + NumberConf, +) +from .dpt import get_supported_dpts +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule -from .schema import NumberSchema +from .storage.const import CONF_ENTITY, CONF_GA_SENSOR +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -35,52 +50,36 @@ async def async_setup_entry( ) -> None: """Set up number(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.NUMBER] - - async_add_entities(KNXNumber(knx_module, entity_config) for entity_config in 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], + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.NUMBER, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiNumber, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.NUMBER): + entities.extend( + KnxYamlNumber(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.NUMBER): + entities.extend( + KnxUiNumber(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) -class KNXNumber(KnxYamlEntity, RestoreNumber): + +class _KnxNumber(RestoreNumber): """Representation of a KNX number.""" _device: NumericValue - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize a KNX number.""" - super().__init__( - knx_module=knx_module, - device=_create_numeric_value(knx_module.xknx, config), - ) - self._attr_native_max_value = config.get( - NumberSchema.CONF_MAX, - self._device.sensor_value.dpt_class.value_max, - ) - self._attr_native_min_value = config.get( - NumberSchema.CONF_MIN, - self._device.sensor_value.dpt_class.value_min, - ) - self._attr_mode = config[CONF_MODE] - self._attr_native_step = config.get( - NumberSchema.CONF_STEP, - self._device.sensor_value.dpt_class.resolution, - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_unique_id = str(self._device.sensor_value.group_address) - self._attr_native_unit_of_measurement = self._device.unit_of_measurement() - self._device.sensor_value.value = max(0, self._attr_native_min_value) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -101,3 +100,102 @@ class KNXNumber(KnxYamlEntity, RestoreNumber): async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self._device.set(value) + + +class KnxYamlNumber(_KnxNumber, KnxYamlEntity): + """Representation of a KNX number configured from YAML.""" + + _device: NumericValue + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize a KNX number.""" + super().__init__( + knx_module=knx_module, + device=NumericValue( + knx_module.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], + ), + ) + self._attr_native_max_value = config.get( + NumberConf.MAX, + self._device.sensor_value.dpt_class.value_max, + ) + self._attr_native_min_value = config.get( + NumberConf.MIN, + self._device.sensor_value.dpt_class.value_min, + ) + self._attr_mode = config[CONF_MODE] + self._attr_native_step = config.get( + NumberConf.STEP, + self._device.sensor_value.dpt_class.resolution, + ) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.sensor_value.group_address) + self._attr_native_unit_of_measurement = self._device.unit_of_measurement() + self._device.sensor_value.value = max(0, self._attr_native_min_value) + + +class KnxUiNumber(_KnxNumber, KnxUiEntity): + """Representation of a KNX number configured from UI.""" + + _device: NumericValue + + def __init__( + self, + knx_module: KNXModule, + unique_id: str, + config: ConfigType, + ) -> None: + """Initialize a KNX number.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + knx_conf = ConfigExtractor(config[DOMAIN]) + dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR) + assert dpt_string is not None # required for number + dpt_info = get_supported_dpts()[dpt_string] + + self._device = NumericValue( + knx_module.xknx, + name=config[CONF_ENTITY][CONF_NAME], + group_address=knx_conf.get_write(CONF_GA_SENSOR), + group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR), + respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), + sync_state=knx_conf.get(CONF_SYNC_STATE), + value_type=dpt_string, + ) + + if device_class_override := knx_conf.get(CONF_DEVICE_CLASS): + self._attr_device_class = try_parse_enum( + NumberDeviceClass, device_class_override + ) + else: + self._attr_device_class = try_parse_enum( + # sensor device classes should, with some exceptions ("enum" etc.), align with number device classes + NumberDeviceClass, + dpt_info["sensor_device_class"], + ) + self._attr_mode = NumberMode(knx_conf.get(CONF_MODE)) + self._attr_native_max_value = knx_conf.get( + NumberConf.MAX, + default=self._device.sensor_value.dpt_class.value_max, + ) + self._attr_native_min_value = knx_conf.get( + NumberConf.MIN, + default=self._device.sensor_value.dpt_class.value_min, + ) + self._attr_native_step = knx_conf.get( + NumberConf.STEP, + default=self._device.sensor_value.dpt_class.resolution, + ) + self._attr_native_unit_of_measurement = ( + knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"] + ) + + self._device.sensor_value.value = max(0, self._attr_native_min_value) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index d1dde265f11..e5db0e650bd 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -5,7 +5,6 @@ from __future__ import annotations from abc import ABC from collections import OrderedDict from datetime import timedelta -import math from typing import ClassVar, Final import voluptuous as vol @@ -62,6 +61,7 @@ from .const import ( CoverConf, FanConf, FanZeroMode, + NumberConf, SceneConf, ) from .validation import ( @@ -73,47 +73,18 @@ from .validation import ( sensor_type_validator, string_type_validator, sync_state_validator, + validate_number_attributes, ) ################## # KNX SUB VALIDATORS ################## -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.") - # Infinity 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 == -math.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 == math.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 _number_limit_sub_validator(config: dict) -> dict: + """Validate min, max, and step values for a number entity.""" + transcoder = DPTNumeric.parse_transcoder(config[CONF_TYPE]) + assert transcoder is not None # already checked by numeric_type_validator + return validate_number_attributes(transcoder, config) def _max_payload_value(payload_length: int) -> int: @@ -791,10 +762,6 @@ class NumberSchema(KNXPlatformSchema): """Voluptuous schema for KNX numbers.""" PLATFORM = Platform.NUMBER - - CONF_MAX = "max" - CONF_MIN = "min" - CONF_STEP = "step" DEFAULT_NAME = "KNX Number" ENTITY_SCHEMA = vol.All( @@ -808,13 +775,13 @@ class NumberSchema(KNXPlatformSchema): 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, + vol.Optional(NumberConf.MAX): vol.Coerce(float), + vol.Optional(NumberConf.MIN): vol.Coerce(float), + vol.Optional(NumberConf.STEP): cv.positive_float, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ), - number_limit_sub_validator, + _number_limit_sub_validator, ) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 7025dbda91d..cef993ca355 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -3,12 +3,18 @@ from enum import StrEnum, unique import voluptuous as vol +from xknx.dpt import DPTNumeric from homeassistant.components.climate import HVACMode +from homeassistant.components.number import ( + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, + NumberDeviceClass, + NumberMode, +) from homeassistant.components.sensor import ( CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS, DEVICE_CLASS_STATE_CLASSES, - DEVICE_CLASS_UNITS, + DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, STATE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, @@ -42,9 +48,11 @@ from ..const import ( CoverConf, FanConf, FanZeroMode, + NumberConf, SceneConf, ) from ..dpt import get_supported_dpts +from ..validation import validate_number_attributes from .const import ( CONF_ALWAYS_CALLBACK, CONF_COLOR, @@ -424,6 +432,65 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst( ), ) + +def _number_limit_sub_validator(config: dict) -> dict: + """Validate min, max, and step values for a number entity.""" + dpt = config[CONF_GA_SENSOR][CONF_DPT] + transcoder = DPTNumeric.parse_transcoder(dpt) + assert transcoder is not None # already checked by GASelector + return validate_number_attributes(transcoder, config) + + +NUMBER_KNX_SCHEMA = AllSerializeFirst( + vol.Schema( + { + vol.Required(CONF_GA_SENSOR): GASelector( + write_required=True, dpt=["numeric"] + ), + vol.Optional( + CONF_RESPOND_TO_READ, default=False + ): selector.BooleanSelector(), + "section_advanced_options": KNXSectionFlat(collapsible=True), + vol.Required(CONF_MODE, default=NumberMode.AUTO): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(NumberMode), + translation_key="component.knx.config_panel.entities.create.number.knx.mode", + ), + ), + vol.Optional(NumberConf.MIN): selector.NumberSelector(), + vol.Optional(NumberConf.MAX): selector.NumberSelector(), + vol.Optional(NumberConf.STEP): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, step="any", mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( + selector.SelectSelectorConfig( + options=sorted( + { + str(unit) + for units in NUMBER_DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + } + ), + mode=selector.SelectSelectorMode.DROPDOWN, + custom_value=True, + ), + ), + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in NumberDeviceClass], + translation_key="component.knx.selector.sensor_device_class", # should align with sensor + sort=True, + ) + ), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, + ), + _number_limit_sub_validator, +) + SCENE_KNX_SCHEMA = vol.Schema( { vol.Required(CONF_GA_SCENE): GASelector( @@ -646,7 +713,7 @@ def _validate_sensor_attributes(config: dict) -> dict: ) if ( device_class - and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None + and (d_c_units := SENSOR_DEVICE_CLASS_UNITS.get(device_class)) is not None and unit_of_measurement not in d_c_units ): raise vol.Invalid( @@ -687,7 +754,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst( options=sorted( { str(unit) - for units in DEVICE_CLASS_UNITS.values() + for units in SENSOR_DEVICE_CLASS_UNITS.values() for unit in units if unit is not None } @@ -732,6 +799,7 @@ KNX_SCHEMA_FOR_PLATFORM = { Platform.DATETIME: DATETIME_KNX_SCHEMA, Platform.FAN: FAN_KNX_SCHEMA, Platform.LIGHT: LIGHT_KNX_SCHEMA, + Platform.NUMBER: NUMBER_KNX_SCHEMA, Platform.SCENE: SCENE_KNX_SCHEMA, Platform.SENSOR: SENSOR_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA, diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 9a952044246..df7ccbbeb96 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -802,6 +802,48 @@ } } }, + "number": { + "description": "Entity for numeric datapoints. Temperature, percent, etc.", + "knx": { + "device_class": { + "description": "[%key:component::knx::config_panel::entities::create::sensor::knx::device_class::description%]", + "label": "[%key:component::knx::config_panel::entities::create::sensor::knx::device_class::label%]" + }, + "ga_sensor": { + "description": "Group address representing value.", + "label": "Value" + }, + "max": { + "description": "Override the DPT's maximum value allowed to be set.", + "label": "Maximum value" + }, + "min": { + "description": "Override the DPT's minimum value allowed to be set.", + "label": "Minimum value" + }, + "mode": { + "description": "[%key:component::knx::config_panel::entities::create::text::knx::mode::description%]", + "label": "[%key:common::config_flow::data::mode%]", + "options": { + "auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]", + "box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]", + "slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]" + } + }, + "section_advanced_options": { + "description": "Override default DPT-based entity attributes.", + "title": "[%key:component::knx::config_panel::entities::create::sensor::knx::section_advanced_options::title%]" + }, + "step": { + "description": "Override the DPT's smallest step size to change the value.", + "label": "Step size" + }, + "unit_of_measurement": { + "description": "[%key:component::knx::config_panel::entities::create::sensor::knx::unit_of_measurement::description%]", + "label": "[%key:component::knx::config_panel::entities::create::sensor::knx::unit_of_measurement::label%]" + } + } + }, "scene": { "description": "A KNX entity can activate a KNX scene and updates when the scene number is received.", "knx": { @@ -823,7 +865,7 @@ "label": "Force update" }, "device_class": { - "description": "Override the DPTs default device class.", + "description": "Override the DPT's default device class.", "label": "Device class" }, "ga_sensor": { @@ -835,11 +877,11 @@ "title": "Overrides" }, "state_class": { - "description": "Override the DPTs default state class.", + "description": "Override the DPT's default state class.", "label": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]" }, "unit_of_measurement": { - "description": "Override the DPTs default unit of measurement.", + "description": "Override the DPT's default unit of measurement.", "label": "Unit of measurement" } } diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index 6a2224c5561..280ffc6b967 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -3,6 +3,7 @@ from collections.abc import Callable from enum import Enum import ipaddress +import math from typing import Any import voluptuous as vol @@ -10,8 +11,15 @@ from xknx.dpt import DPTBase, DPTNumeric, DPTString from xknx.exceptions import CouldNotParseAddress from xknx.telegram.address import IndividualAddress, parse_device_group_address +from homeassistant.components.number import ( + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT from homeassistant.helpers import config_validation as cv +from .const import NumberConf +from .dpt import get_supported_dpts + def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: """Validate that value is parsable as given sensor type.""" @@ -138,3 +146,76 @@ def backwards_compatible_xknx_climate_enum_member(enumClass: type[Enum]) -> vol. vol.In(enumClass.__members__), enumClass.__getitem__, ) + + +def validate_number_attributes( + transcoder: type[DPTNumeric], config: dict[str, Any] +) -> dict[str, Any]: + """Validate a number entity configurations dependent on configured value type. + + Works for both, UI and YAML configuration schema since they + share same names for all tested attributes. + """ + min_config: float | None = config.get(NumberConf.MIN) + max_config: float | None = config.get(NumberConf.MAX) + step_config: float | None = config.get(NumberConf.STEP) + _dpt_error_str = f"DPT {transcoder.dpt_number_str()} '{transcoder.value_type}'" + + # Infinity is not supported by Home Assistant frontend so user defined + # config is required if xknx DPTNumeric subclass defines it as limit. + if min_config is None and transcoder.value_min == -math.inf: + raise vol.Invalid( + f"'min' key required for {_dpt_error_str}", + path=[NumberConf.MIN], + ) + if min_config is not None and min_config < transcoder.value_min: + raise vol.Invalid( + f"'min: {min_config}' undercuts possible minimum" + f" of {_dpt_error_str}: {transcoder.value_min}", + path=[NumberConf.MIN], + ) + if max_config is None and transcoder.value_max == math.inf: + raise vol.Invalid( + f"'max' key required for {_dpt_error_str}", + path=[NumberConf.MAX], + ) + if max_config is not None and max_config > transcoder.value_max: + raise vol.Invalid( + f"'max: {max_config}' exceeds possible maximum" + f" of {_dpt_error_str}: {transcoder.value_max}", + path=[NumberConf.MAX], + ) + if step_config is not None and step_config < transcoder.resolution: + raise vol.Invalid( + f"'step: {step_config}' undercuts possible minimum step" + f" of {_dpt_error_str}: {transcoder.resolution}", + path=[NumberConf.STEP], + ) + + # Validate device class and unit of measurement compatibility + dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()] + + device_class = config.get( + CONF_DEVICE_CLASS, + dpt_metadata["sensor_device_class"], + ) + unit_of_measurement = config.get( + CONF_UNIT_OF_MEASUREMENT, + dpt_metadata["unit"], + ) + if ( + device_class + and (d_c_units := NUMBER_DEVICE_CLASS_UNITS.get(device_class)) is not None + and unit_of_measurement not in d_c_units + ): + raise vol.Invalid( + f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. " + f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}", + path=( + [CONF_DEVICE_CLASS] + if CONF_DEVICE_CLASS in config + else [CONF_UNIT_OF_MEASUREMENT] + ), + ) + + return config diff --git a/tests/components/knx/fixtures/config_store_number.json b/tests/components/knx/fixtures/config_store_number.json new file mode 100644 index 00000000000..4b8341e8a80 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_number.json @@ -0,0 +1,52 @@ +{ + "version": 2, + "minor_version": 2, + "key": "knx/config_store.json", + "data": { + "entities": { + "number": { + "knx_es_01KFD3W6X5F24HSNSJK3EQDJ4G": { + "entity": { + "name": "test simple", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_sensor": { + "write": "1/0/1", + "dpt": "5.001", + "state": null, + "passive": [] + }, + "sync_state": true, + "respond_to_read": false, + "mode": "auto" + } + }, + "knx_es_01KFD3ZRZ4YXY3GHQM7TQZJVPV": { + "entity": { + "name": "test options", + "entity_category": null, + "device_info": null + }, + "knx": { + "ga_sensor": { + "write": "2/0/1", + "dpt": "7", + "state": "2/0/0", + "passive": [] + }, + "mode": "slider", + "min": 3000, + "max": 5000, + "step": 100, + "unit_of_measurement": "kW", + "device_class": "power", + "sync_state": true, + "respond_to_read": false + } + } + } + } + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index da05f3e2f73..2e79ae06e46 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -1576,6 +1576,373 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[number] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_sensor', + 'options': dict({ + 'dptClasses': list([ + 'numeric', + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': False, + 'name': 'respond_to_read', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_advanced_options', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'default': 'auto', + 'name': 'mode', + 'required': True, + 'selector': dict({ + 'select': dict({ + 'custom_value': False, + 'multiple': False, + 'options': list([ + 'auto', + 'box', + 'slider', + ]), + 'sort': False, + 'translation_key': 'component.knx.config_panel.entities.create.number.knx.mode', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'min', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'mode': 'box', + 'step': 1.0, + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'max', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'mode': 'box', + 'step': 1.0, + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'step', + 'optional': True, + 'required': False, + 'selector': dict({ + 'number': dict({ + 'min': 0.0, + 'mode': 'box', + 'step': 'any', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'unit_of_measurement', + 'optional': True, + 'required': False, + 'selector': dict({ + 'select': dict({ + 'custom_value': True, + 'mode': 'dropdown', + 'multiple': False, + 'options': list([ + '%', + 'A', + 'B', + 'B/s', + 'BTU/(h⋅ft²)', + 'Beaufort', + 'CCF', + 'EB', + 'EiB', + 'GB', + 'GB/s', + 'GHz', + 'GJ', + 'GW', + 'GWh', + 'Gbit', + 'Gbit/s', + 'Gcal', + 'GiB', + 'GiB/s', + 'Hz', + 'J', + 'K', + 'KiB', + 'KiB/s', + 'L', + 'L/h', + 'L/min', + 'L/s', + 'MB', + 'MB/s', + 'MCF', + 'MHz', + 'MJ', + 'MV', + 'MW', + 'MWh', + 'Mbit', + 'Mbit/s', + 'Mcal', + 'MiB', + 'MiB/s', + 'PB', + 'Pa', + 'PiB', + 'S/cm', + 'TB', + 'TW', + 'TWh', + 'TiB', + 'V', + 'VA', + 'W', + 'W/m²', + 'Wh', + 'Wh/km', + 'YB', + 'YiB', + 'ZB', + 'ZiB', + 'ac', + 'bar', + 'bit', + 'bit/s', + 'cal', + 'cbar', + 'cm', + 'cm²', + 'd', + 'dB', + 'dBA', + 'dBm', + 'fl. oz.', + 'ft', + 'ft/s', + 'ft²', + 'ft³', + 'ft³/min', + 'g', + 'g/m³', + 'gal', + 'gal/d', + 'gal/h', + 'gal/min', + 'h', + 'hPa', + 'ha', + 'in', + 'in/d', + 'in/h', + 'in/s', + 'inHg', + 'inH₂O', + 'in²', + 'kB', + 'kB/s', + 'kHz', + 'kJ', + 'kPa', + 'kV', + 'kVA', + 'kW', + 'kWh', + 'kWh/100km', + 'kbit', + 'kbit/s', + 'kcal', + 'kg', + 'km', + 'km/h', + 'km/kWh', + 'km²', + 'kn', + 'kvar', + 'kvarh', + 'lb', + 'lx', + 'm', + 'm/min', + 'm/s', + 'mA', + 'mL', + 'mL/s', + 'mPa', + 'mS/cm', + 'mV', + 'mVA', + 'mW', + 'mWh', + 'mbar', + 'mg', + 'mg/dL', + 'mg/m³', + 'mi', + 'mi/kWh', + 'min', + 'mi²', + 'mm', + 'mm/d', + 'mm/h', + 'mm/s', + 'mmHg', + 'mmol/L', + 'mm²', + 'mph', + 'ms', + 'mvar', + 'm²', + 'm³', + 'm³/h', + 'm³/min', + 'm³/s', + 'nmi', + 'oz', + 'ppb', + 'ppm', + 'psi', + 's', + 'st', + 'var', + 'varh', + 'yd', + 'yd²', + '°', + '°C', + '°F', + 'μS/cm', + 'μV', + 'μg', + 'μg/m³', + 'μs', + ]), + 'sort': False, + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'device_class', + 'optional': True, + 'required': False, + 'selector': dict({ + 'select': dict({ + 'custom_value': False, + 'multiple': False, + 'options': list([ + 'absolute_humidity', + 'apparent_power', + 'aqi', + 'area', + 'atmospheric_pressure', + 'battery', + 'blood_glucose_concentration', + 'carbon_monoxide', + 'carbon_dioxide', + 'conductivity', + 'current', + 'data_rate', + 'data_size', + 'distance', + 'duration', + 'energy', + 'energy_distance', + 'energy_storage', + 'frequency', + 'gas', + 'humidity', + 'illuminance', + 'irradiance', + 'moisture', + 'monetary', + 'nitrogen_dioxide', + 'nitrogen_monoxide', + 'nitrous_oxide', + 'ozone', + 'ph', + 'pm1', + 'pm10', + 'pm25', + 'pm4', + 'power_factor', + 'power', + 'precipitation', + 'precipitation_intensity', + 'pressure', + 'reactive_energy', + 'reactive_power', + 'signal_strength', + 'sound_pressure', + 'speed', + 'sulphur_dioxide', + 'temperature', + 'temperature_delta', + 'volatile_organic_compounds', + 'volatile_organic_compounds_parts', + 'voltage', + 'volume', + 'volume_storage', + 'volume_flow_rate', + 'water', + 'weight', + 'wind_direction', + 'wind_speed', + ]), + 'sort': True, + 'translation_key': 'component.knx.selector.sensor_device_class', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[scene] dict({ 'id': 1, diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index 5eec4530d4e..2c24e289011 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -1,13 +1,16 @@ """Test KNX number.""" +from typing import Any + import pytest from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS from homeassistant.components.knx.schema import NumberSchema -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import mock_restore_cache_with_extra_data @@ -106,3 +109,101 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) await knx.receive_write(test_passive_address, (0x4E, 0xDE)) state = hass.states.get("number.test") assert state.state == "9000.96" + + +@pytest.mark.parametrize( + ("knx_config", "set_value", "expected_telegram", "expected_state"), + [ + ( + { + "ga_sensor": { + "write": "1/1/1", + "dpt": "5.001", # percentU8 + }, + }, + 50.0, + (0x80,), + { + "state": "50", + "device_class": None, + "unit_of_measurement": "%", + "min": 0, + "max": 100, + "step": 1, + }, + ), + ( + { + "ga_sensor": { + "write": "1/1/1", + "dpt": "9.001", # temperature 2 byte float + "passive": [], + }, + "sync_state": True, + "respond_to_read": True, + }, + 21.5, + (0x0C, 0x33), + { + "state": "21.5", + "device_class": "temperature", # from DPT + "unit_of_measurement": "°C", + "min": -273.0, + "max": 670760.0, + "step": 0.01, + }, + ), + ], +) +async def test_number_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, + knx_config: dict[str, Any], + set_value: float, + expected_telegram: tuple[int, ...], + expected_state: dict[str, Any], +) -> None: + """Test creating a number entity.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.NUMBER, + entity_data={"name": "test"}, + knx_data=knx_config, + ) + # set value + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.test", "value": set_value}, + blocking=True, + ) + await knx.assert_write("1/1/1", expected_telegram) + knx.assert_state("number.test", **expected_state) + + +async def test_number_ui_load(knx: KNXTestKit) -> None: + """Test loading number entities from storage.""" + await knx.setup_integration(config_store_fixture="config_store_number.json") + + await knx.assert_read("2/0/0", response=(0x0B, 0xB8)) # 3000 + knx.assert_state( + "number.test_simple", + "0", # 0 is default value + unit_of_measurement="%", # from DPT + device_class=None, # default values + mode="auto", + min=0, + max=100, + step=1, + ) + knx.assert_state( + "number.test_options", + "3000", + unit_of_measurement="kW", + device_class="power", + min=3000, + max=5000, + step=100, + mode="slider", + )