Compare commits

...

5 Commits

Author SHA1 Message Date
farmio
e43cac2703 genitive 2026-01-20 15:57:32 +01:00
farmio
0b488bb7b0 typo in comment 2026-01-20 09:24:47 +01:00
farmio
64946aa379 remove unused code 2026-01-20 09:22:50 +01:00
farmio
ecbb034124 update snapshots and typos 2026-01-20 09:20:28 +01:00
farmio
25591df23a Support KNX number entity configuration from UI 2026-01-20 09:02:07 +01:00
9 changed files with 881 additions and 96 deletions

View File

@@ -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."""

View File

@@ -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)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from abc import ABC
from collections import OrderedDict
import math
from typing import ClassVar, Final
import voluptuous as vol
@@ -61,6 +60,7 @@ from .const import (
CoverConf,
FanConf,
FanZeroMode,
NumberConf,
SceneConf,
)
from .validation import (
@@ -72,47 +72,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:
@@ -784,10 +755,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(
@@ -801,13 +768,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,
)

View File

@@ -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,

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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
}
}
}
}
}
}

View File

@@ -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,

View File

@@ -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",
)