mirror of
https://github.com/home-assistant/core.git
synced 2026-01-21 06:57:01 +01:00
Compare commits
5 Commits
homevolt
...
knx-number
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e43cac2703 | ||
|
|
0b488bb7b0 | ||
|
|
64946aa379 | ||
|
|
ecbb034124 | ||
|
|
25591df23a |
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
52
tests/components/knx/fixtures/config_store_number.json
Normal file
52
tests/components/knx/fixtures/config_store_number.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user