mirror of
https://github.com/home-assistant/core.git
synced 2026-09-22 01:33:37 +01:00
KNX: Validate entity_category for read-only platforms (#182141)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user