From 89b4a9a46b9fa7fc1ac131c4e074a4bd751a63bf Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 16 Sep 2026 08:20:09 +0200 Subject: [PATCH] KNX: Validate entity_category for read-only platforms (#182141) --- homeassistant/components/knx/const.py | 5 + homeassistant/components/knx/schema.py | 6 +- .../components/knx/storage/config_store.py | 6 +- .../knx/storage/entity_store_schema.py | 60 ++++++------ .../components/knx/storage/migration.py | 15 ++- homeassistant/components/knx/validation.py | 41 ++++++++- .../fixtures/config_store_binarysensor.json | 2 +- .../config_store_entity_category.json | 69 ++++++++++++++ .../config_store_entity_category_v2_4.json | 69 ++++++++++++++ .../knx/fixtures/config_store_light.json | 2 +- tests/components/knx/test_config_store.py | 91 ++++++++++++++++++- tests/components/knx/test_sensor.py | 3 +- 12 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 tests/components/knx/fixtures/config_store_entity_category.json create mode 100644 tests/components/knx/fixtures/config_store_entity_category_v2_4.json diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 5f89df8c2285..5a690b5af5af 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -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, diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 5a80850e175f..31e50c7eb9a4 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -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) ), diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2b1da7940313..35d4c422a8e7 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -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 diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 2616d0a795f8..933faf903751 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -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 diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py index e4c33e319d16..eaf5b1c071f4 100644 --- a/homeassistant/components/knx/storage/migration.py +++ b/homeassistant/components/knx/storage/migration.py @@ -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 diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py index dd76188646b3..a7221c05b782 100644 --- a/homeassistant/components/knx/validation.py +++ b/homeassistant/components/knx/validation.py @@ -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)): diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json index 03d5446ace11..250283920a17 100644 --- a/tests/components/knx/fixtures/config_store_binarysensor.json +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 4, + "minor_version": 5, "key": "knx/config_store.json", "data": { "entities": { diff --git a/tests/components/knx/fixtures/config_store_entity_category.json b/tests/components/knx/fixtures/config_store_entity_category.json new file mode 100644 index 000000000000..1b67d0663300 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_entity_category.json @@ -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": {} + } +} diff --git a/tests/components/knx/fixtures/config_store_entity_category_v2_4.json b/tests/components/knx/fixtures/config_store_entity_category_v2_4.json new file mode 100644 index 000000000000..060ee4ed2787 --- /dev/null +++ b/tests/components/knx/fixtures/config_store_entity_category_v2_4.json @@ -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": {} + } +} diff --git a/tests/components/knx/fixtures/config_store_light.json b/tests/components/knx/fixtures/config_store_light.json index 634ac9290afe..d82c57983653 100644 --- a/tests/components/knx/fixtures/config_store_light.json +++ b/tests/components/knx/fixtures/config_store_light.json @@ -1,6 +1,6 @@ { "version": 2, - "minor_version": 4, + "minor_version": 5, "key": "knx/config_store.json", "data": { "entities": { diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 04f7618c8e3c..4036571d2d47 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -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") diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index 7a18bce7a19b..bf280e56e847 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -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( {