KNX: Validate entity_category for read-only platforms (#182141)

This commit is contained in:
Matthias Alphart
2026-09-16 08:20:09 +02:00
committed by GitHub
parent 32ad04c584
commit 89b4a9a46b
12 changed files with 331 additions and 38 deletions
+5
View File
@@ -198,6 +198,11 @@ SUPPORTED_PLATFORMS_YAML: Final = {
Platform.WEATHER,
}
# read-only platforms raising when added with `EntityCategory.CONFIG`
PLATFORMS_WITHOUT_CONFIG_CATEGORY: Final = frozenset(
{Platform.BINARY_SENSOR, Platform.SENSOR}
)
SUPPORTED_PLATFORMS_UI: Final = {
Platform.BINARY_SENSOR,
Platform.BUTTON,
+4 -2
View File
@@ -48,7 +48,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA
from homeassistant.util import slugify
from .const import (
@@ -78,6 +77,7 @@ from .dpt import get_supported_dpts
from .validation import (
backwards_compatible_xknx_climate_enum_member,
dpt_base_type_validator,
entity_category_validator,
ga_list_validator,
ga_validator,
numeric_type_validator,
@@ -260,7 +260,9 @@ def _entity_base_schema(platform: Platform) -> probatio.Schema:
probatio.Optional(CONF_DEFAULT_ENTITY_ID): probatio.All(
cv.entity_id, cv.entity_domain(platform)
),
probatio.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
probatio.Optional(CONF_ENTITY_CATEGORY): entity_category_validator(
platform
),
probatio.Optional(CONF_UNIQUE_ID): probatio.All(
cv.string, probatio.Length(min=1)
),
@@ -25,7 +25,7 @@ from .time_server import KNXTimeServerStoreModel
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION: Final = 2
STORAGE_VERSION_MINOR: Final = 4
STORAGE_VERSION_MINOR: Final = 5
STORAGE_KEY: Final = f"{DOMAIN}/config_store.json"
type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration
@@ -80,6 +80,10 @@ class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]):
# version 2.4 introduced in 2026.5
migration.migrate_2_3_to_2_4(old_data)
if old_major_version <= 2 and old_minor_version < 5:
# version 2.5 introduced in 2026.10
migration.migrate_2_4_to_2_5(old_data)
return old_data
@@ -30,7 +30,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.helpers import selector
from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA
from homeassistant.helpers.typing import VolDictType
from ..const import (
@@ -54,7 +53,11 @@ from ..const import (
SelectConf,
)
from ..dpt import get_supported_dpts, raw_payload_length
from ..validation import validate_number_attributes, validate_sensor_attributes
from ..validation import (
entity_category_validator,
validate_number_attributes,
validate_sensor_attributes,
)
from .const import (
CONF_ALWAYS_CALLBACK,
CONF_COLOR,
@@ -140,30 +143,33 @@ from .knx_selector import (
SyncStateSelector,
)
BASE_ENTITY_SCHEMA = probatio.All(
{
probatio.Optional(CONF_NAME, default=None): probatio.Maybe(str),
probatio.Optional(CONF_DEVICE_INFO, default=None): probatio.Maybe(str),
probatio.Optional(CONF_ENTITY_CATEGORY, default=None): probatio.Any(
ENTITY_CATEGORIES_SCHEMA, probatio.SetTo(None)
def base_entity_schema(platform: Platform) -> probatio.All:
"""Return the base entity schema for a platform."""
return probatio.All(
{
probatio.Optional(CONF_NAME, default=None): probatio.Maybe(str),
probatio.Optional(CONF_DEVICE_INFO, default=None): probatio.Maybe(str),
probatio.Optional(
CONF_ENTITY_CATEGORY, default=None
): entity_category_validator(platform),
},
probatio.Any(
probatio.Schema(
{
probatio.Required(CONF_NAME): probatio.All(str, probatio.IsTrue()),
},
extra=probatio.ALLOW_EXTRA,
),
probatio.Schema(
{
probatio.Required(CONF_DEVICE_INFO): str,
},
extra=probatio.ALLOW_EXTRA,
),
msg="One of `Device` or `Name` is required",
),
},
probatio.Any(
probatio.Schema(
{
probatio.Required(CONF_NAME): probatio.All(str, probatio.IsTrue()),
},
extra=probatio.ALLOW_EXTRA,
),
probatio.Schema(
{
probatio.Required(CONF_DEVICE_INFO): str,
},
extra=probatio.ALLOW_EXTRA,
),
msg="One of `Device` or `Name` is required",
),
)
)
BINARY_SENSOR_KNX_SCHEMA = probatio.Schema(
@@ -1045,7 +1051,9 @@ ENTITY_STORE_DATA_SCHEMA = probatio.All(
{
probatio.Required(CONF_DATA): probatio.Schema(
{
probatio.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA,
probatio.Required(CONF_ENTITY): base_entity_schema(
platform
),
probatio.Required(DOMAIN): knx_schema,
},
extra=probatio.PREVENT_EXTRA, # restrict in data key for yaml edit
@@ -2,9 +2,9 @@
from typing import Any
from homeassistant.const import Platform
from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory, Platform
from ..const import CONF_RESPOND_TO_READ
from ..const import CONF_RESPOND_TO_READ, PLATFORMS_WITHOUT_CONFIG_CATEGORY
from . import const as store_const
@@ -60,3 +60,14 @@ def migrate_2_2_to_2_3(data: dict[str, Any]) -> None:
def migrate_2_3_to_2_4(data: dict[str, Any]) -> None:
"""Migrate from schema 2.3 to schema 2.4."""
data.setdefault("expose", {})
def migrate_2_4_to_2_5(data: dict[str, Any]) -> None:
"""Migrate from schema 2.4 to schema 2.5."""
for platform in PLATFORMS_WITHOUT_CONFIG_CATEGORY:
for entity in data.get("entities", {}).get(platform, {}).values():
# `EntityCategory.CONFIG` is not valid for these platforms
# so these entities were never set up
entity_data = entity[store_const.CONF_ENTITY]
if entity_data.get(CONF_ENTITY_CATEGORY) == EntityCategory.CONFIG:
entity_data[CONF_ENTITY_CATEGORY] = EntityCategory.DIAGNOSTIC
+39 -2
View File
@@ -20,10 +20,15 @@ from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS,
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
EntityCategory,
Platform,
)
from homeassistant.helpers import config_validation as cv
from .const import NumberConf
from .const import PLATFORMS_WITHOUT_CONFIG_CATEGORY, NumberConf
from .dpt import DPTInfo, get_supported_dpts
@@ -51,6 +56,38 @@ string_type_validator = dpt_subclass_validator(DPTString)
sensor_type_validator = probatio.Any(numeric_type_validator, string_type_validator)
def entity_category_validator(
platform: Platform,
) -> Callable[[Any], EntityCategory | None]:
"""Validate the entity category is supported by the platform.
Works for both, UI and YAML configuration schema.
"""
valid_categories = set(EntityCategory)
if platform in PLATFORMS_WITHOUT_CONFIG_CATEGORY:
valid_categories -= {EntityCategory.CONFIG}
def validate(value: Any) -> EntityCategory | None:
"""Validate the entity category."""
if value is None or value == "": # UI sends an empty value to clear it
return None
try:
entity_category = EntityCategory(value)
except ValueError:
raise probatio.Invalid(
f"'{value}' is not a valid entity category"
) from None
if entity_category not in valid_categories:
_options = ", ".join(sorted(valid_categories))
raise probatio.Invalid(
f"Entity category '{entity_category}' is not supported by the"
f" {platform} platform. Valid options are: {_options}"
)
return entity_category
return validate
def ga_validator(value: Any) -> str | int:
"""Validate that value is parsable as GroupAddress or InternalGroupAddress."""
if not isinstance(value, (str, int)):
@@ -1,6 +1,6 @@
{
"version": 2,
"minor_version": 4,
"minor_version": 5,
"key": "knx/config_store.json",
"data": {
"entities": {
@@ -0,0 +1,69 @@
{
"version": 2,
"minor_version": 5,
"key": "knx/config_store.json",
"data": {
"entities": {
"sensor": {
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSJ": {
"entity": {
"name": "Test sensor",
"device_info": null,
"entity_category": "diagnostic"
},
"knx": {
"ga_sensor": {
"state": "1/1/1",
"dpt": "7.600",
"passive": []
},
"always_callback": false,
"sync_state": true
}
}
},
"binary_sensor": {
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSK": {
"entity": {
"name": "Test binary sensor",
"device_info": null,
"entity_category": "diagnostic"
},
"knx": {
"ga_sensor": {
"state": "2/2/2",
"dpt": "1",
"passive": []
},
"invert": false,
"ignore_internal_state": false,
"context_timeout": 0.0,
"reset_after": 0.0,
"sync_state": true
}
}
},
"switch": {
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSL": {
"entity": {
"name": "Test switch",
"device_info": null,
"entity_category": "config"
},
"knx": {
"ga_switch": {
"write": "3/3/3",
"state": null,
"passive": []
},
"invert": false,
"respond_to_read": false,
"sync_state": true
}
}
}
},
"expose": {},
"time_server": {}
}
}
@@ -0,0 +1,69 @@
{
"version": 2,
"minor_version": 4,
"key": "knx/config_store.json",
"data": {
"entities": {
"sensor": {
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSJ": {
"entity": {
"name": "Test sensor",
"device_info": null,
"entity_category": "config"
},
"knx": {
"ga_sensor": {
"state": "1/1/1",
"dpt": "7.600",
"passive": []
},
"always_callback": false,
"sync_state": true
}
}
},
"binary_sensor": {
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSK": {
"entity": {
"name": "Test binary sensor",
"device_info": null,
"entity_category": "config"
},
"knx": {
"ga_sensor": {
"state": "2/2/2",
"dpt": "1",
"passive": []
},
"invert": false,
"ignore_internal_state": false,
"context_timeout": 0.0,
"reset_after": 0.0,
"sync_state": true
}
}
},
"switch": {
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSL": {
"entity": {
"name": "Test switch",
"device_info": null,
"entity_category": "config"
},
"knx": {
"ga_switch": {
"write": "3/3/3",
"state": null,
"passive": []
},
"invert": false,
"respond_to_read": false,
"sync_state": true
}
}
}
},
"expose": {},
"time_server": {}
}
}
@@ -1,6 +1,6 @@
{
"version": 2,
"minor_version": 4,
"minor_version": 5,
"key": "knx/config_store.json",
"data": {
"entities": {
+89 -2
View File
@@ -120,6 +120,73 @@ async def test_create_entity_error(
assert res["result"]["error_base"].startswith("value must be one of")
@pytest.mark.parametrize(
("platform", "knx_data", "read_response"),
[
pytest.param(
Platform.SENSOR,
{"ga_sensor": {"state": "1/2/3", "dpt": "5.001"}},
(0,),
id="sensor",
),
pytest.param(
Platform.BINARY_SENSOR,
{"ga_sensor": {"state": "1/2/3", "dpt": "1"}},
0,
id="binary_sensor",
),
],
)
async def test_create_entity_unsupported_entity_category(
hass: HomeAssistant,
knx: KNXTestKit,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
entity_registry: er.EntityRegistry,
create_ui_entity: KnxEntityGenerator,
platform: Platform,
knx_data: dict[str, Any],
read_response: int | tuple[int, ...],
) -> None:
"""Test read-only platforms reject `EntityCategory.CONFIG`."""
await knx.setup_integration()
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "knx/create_entity",
"platform": platform,
"data": {
"entity": {
"name": "Test config category",
"entity_category": EntityCategory.CONFIG,
},
"knx": knx_data,
},
}
)
res = await client.receive_json()
assert res["success"], res
assert not res["result"]["success"]
assert res["result"]["errors"][0]["path"] == ["data", "entity", "entity_category"]
assert "is not supported by the" in res["result"]["error_base"]
assert KNX_CONFIG_STORAGE_KEY not in hass_storage
entity_entry = await create_ui_entity(
platform=platform,
entity_data={
"name": "Test diagnostic category",
"entity_category": EntityCategory.DIAGNOSTIC,
},
knx_data=knx_data,
)
await knx.assert_read("1/2/3", response=read_response)
assert (
entity_registry.async_get(entity_entry.entity_id).entity_category
is EntityCategory.DIAGNOSTIC
)
async def test_update_entity(
hass: HomeAssistant,
knx: KNXTestKit,
@@ -710,12 +777,12 @@ async def test_migration_1_to_2(
assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data
async def test_migration_2_1_to_2_4(
async def test_migration_2_1_to_2_5(
hass: HomeAssistant,
knx: KNXTestKit,
hass_storage: dict[str, Any],
) -> None:
"""Test migration from schema 2.1 to schema 2.4."""
"""Test migration from schema 2.1 to schema 2.5."""
await knx.setup_integration(
config_store_fixture="config_store_binarysensor_v2_1.json",
state_updater=False,
@@ -724,3 +791,23 @@ async def test_migration_2_1_to_2_4(
hass, "config_store_binarysensor.json", "knx"
)
assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data
async def test_migration_2_4_to_2_5(
hass: HomeAssistant,
knx: KNXTestKit,
hass_storage: dict[str, Any],
) -> None:
"""Test migration from schema 2.4 to schema 2.5."""
await knx.setup_integration(
config_store_fixture="config_store_entity_category_v2_4.json",
state_updater=False,
)
new_data = await async_load_json_object_fixture(
hass, "config_store_entity_category.json", "knx"
)
assert hass_storage[KNX_CONFIG_STORAGE_KEY] == new_data
# entities that could not be set up before are now created
assert hass.states.get("sensor.test_sensor")
assert hass.states.get("binary_sensor.test_binary_sensor")
+2 -1
View File
@@ -247,6 +247,7 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None:
{"state_class": "total_increasing"}, # invalid for temperature DPT
{"unit_of_measurement": "invalid"},
{"device_class": "energy", "unit_of_measurement": "invalid"},
{"entity_category": "config"}, # sensors can not be added as config entities
],
)
async def test_sensor_yaml_attribute_validation(
@@ -255,7 +256,7 @@ async def test_sensor_yaml_attribute_validation(
knx: KNXTestKit,
attribute_config: dict[str, Any],
) -> None:
"""Test creating a sensor with invalid unit, state_class or device_class."""
"""Test creating a sensor with invalid attributes."""
with caplog.at_level(logging.ERROR):
await knx.setup_integration(
{