Validate KNX entity store data before setting up UI entities (#180067)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matthias Alphart
2026-08-29 08:20:26 +00:00
committed by Franck Nijhof
co-authored by Claude Opus 5
parent bbe4de36a1
commit 60d579f0ca
29 changed files with 254 additions and 41 deletions
@@ -68,9 +68,7 @@ async def async_setup_entry(
KnxYamlBinarySensor(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(
Platform.BINARY_SENSOR
):
if ui_config := knx_module.config_store.get_entity_configs(Platform.BINARY_SENSOR):
entities.extend(
KnxUiBinarySensor(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -49,7 +49,7 @@ async def async_setup_entry(
KnxYamlButton(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.BUTTON):
if ui_config := knx_module.config_store.get_entity_configs(Platform.BUTTON):
entities.extend(
KnxUiButton(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -103,7 +103,7 @@ async def async_setup_entry(
KnxYamlClimate(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.CLIMATE):
if ui_config := knx_module.config_store.get_entity_configs(Platform.CLIMATE):
entities.extend(
KnxUiClimate(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1
View File
@@ -124,6 +124,7 @@ SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
SERVICE_KNX_READ: Final = "read"
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
REPAIR_ISSUE_ENTITY_VALIDATION_ERROR: Final = "entity_validation_error"
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR: Final = "telegram_backend_error"
+1 -1
View File
@@ -74,7 +74,7 @@ async def async_setup_entry(
KnxYamlCover(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.COVER):
if ui_config := knx_module.config_store.get_entity_configs(Platform.COVER):
entities.extend(
KnxUiCover(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -59,7 +59,7 @@ async def async_setup_entry(
KnxYamlDate(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.DATE):
if ui_config := knx_module.config_store.get_entity_configs(Platform.DATE):
entities.extend(
KnxUiDate(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -60,7 +60,7 @@ async def async_setup_entry(
KnxYamlDateTime(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.DATETIME):
if ui_config := knx_module.config_store.get_entity_configs(Platform.DATETIME):
entities.extend(
KnxUiDateTime(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+2 -4
View File
@@ -14,7 +14,6 @@ from homeassistant.const import (
CONF_ID,
CONF_NAME,
CONF_UNIQUE_ID,
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
@@ -267,7 +266,6 @@ class KnxUiEntity(_KnxEntityBase):
self._attr_name = entity_config[CONF_NAME]
self._attr_unique_id = unique_id
if entity_category := entity_config.get(CONF_ENTITY_CATEGORY):
self._attr_entity_category = EntityCategory(entity_category)
if device_info := entity_config.get(CONF_DEVICE_INFO):
self._attr_entity_category = entity_config[CONF_ENTITY_CATEGORY]
if device_info := entity_config[CONF_DEVICE_INFO]:
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})
+1 -1
View File
@@ -122,7 +122,7 @@ async def async_setup_entry(
KnxYamlFan(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN):
if ui_config := knx_module.config_store.get_entity_configs(Platform.FAN):
entities.extend(
KnxUiFan(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -83,7 +83,7 @@ async def async_setup_entry(
KnxYamlLight(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.LIGHT):
if ui_config := knx_module.config_store.get_entity_configs(Platform.LIGHT):
entities.extend(
KnxUiLight(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -49,7 +49,7 @@ async def async_setup_entry(
KnxYamlNotify(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.NOTIFY):
if ui_config := knx_module.config_store.get_entity_configs(Platform.NOTIFY):
entities.extend(
KnxUiNotify(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -68,7 +68,7 @@ async def async_setup_entry(
KnxYamlNumber(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.NUMBER):
if ui_config := knx_module.config_store.get_entity_configs(Platform.NUMBER):
entities.extend(
KnxUiNumber(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+34
View File
@@ -2,6 +2,7 @@
from collections.abc import Callable
from functools import partial
import logging
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
@@ -9,6 +10,7 @@ from xknx.exceptions.exception import InvalidSecureConfiguration
from xknx.telegram import GroupAddress, IndividualAddress, Telegram
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir, selector
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -21,12 +23,15 @@ from .const import (
CONF_KNX_KNXKEY_PASSWORD,
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
REPAIR_ISSUE_ENTITY_VALIDATION_ERROR,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
KNXConfigEntryData,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
_LOGGER = logging.getLogger(__name__)
CONF_KEYRING_FILE: Final = "knxkeys_file"
@@ -43,6 +48,35 @@ async def async_create_fix_flow(
raise ValueError(f"unknown repair {issue_id}")
###########################
# Entity store schema issue
###########################
@callback
def async_create_entity_validation_issue(
hass: HomeAssistant, platform: Platform, unique_ids: list[str]
) -> None:
"""Create a repair issue for invalid entity configurations in the config store."""
_LOGGER.error(
"Invalid KNX %s configuration in storage. These entities were not set up: %s",
platform,
", ".join(unique_ids),
)
ir.async_create_issue(
hass,
DOMAIN,
f"{REPAIR_ISSUE_ENTITY_VALIDATION_ERROR}_{platform}",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key=REPAIR_ISSUE_ENTITY_VALIDATION_ERROR,
translation_placeholders={
"platform": platform,
"entities": "\n".join(f"- {unique_id}" for unique_id in unique_ids),
},
)
######################
# DataSecure key issue
######################
+1 -1
View File
@@ -51,7 +51,7 @@ async def async_setup_entry(
KnxYamlScene(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SCENE):
if ui_config := knx_module.config_store.get_entity_configs(Platform.SCENE):
entities.extend(
KnxUiScene(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -71,7 +71,7 @@ async def async_setup_entry(
KnxYamlSelect(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SELECT):
if ui_config := knx_module.config_store.get_entity_configs(Platform.SELECT):
entities.extend(
KnxUiSelect(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -156,7 +156,7 @@ async def async_setup_entry(
KnxYamlSensor(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SENSOR):
if ui_config := knx_module.config_store.get_entity_configs(Platform.SENSOR):
entities.extend(
KnxUiSensor(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
@@ -12,8 +12,13 @@ from homeassistant.helpers.storage import Store
from homeassistant.util.ulid import ulid_now
from ..const import DOMAIN, KNX_MODULE_KEY
from ..repairs import async_create_entity_validation_issue
from . import migration
from .const import CONF_DATA
from .entity_store_validation import (
EntityStoreValidationException,
validate_entity_data,
)
from .expose_controller import KNXExposeStoreConfigModel, KNXExposeStoreModel
from .time_server import KNXTimeServerStoreModel
@@ -118,6 +123,28 @@ class KNXConfigStore:
"""Add platform controller."""
self._platform_controllers[platform] = controller
@callback
def get_entity_configs(self, platform: Platform) -> KNXPlatformStoreModel:
"""Return validated entity configurations for a platform.
Invalid configurations are reported as a repair issue and stay in
`self.data` so they aren't dropped from storage.
"""
validated: KNXPlatformStoreModel = {}
invalid: list[str] = []
for unique_id, config in self.data["entities"].get(platform, {}).items():
try:
result = validate_entity_data(
{CONF_PLATFORM: platform, CONF_DATA: config}
)
except EntityStoreValidationException:
invalid.append(unique_id)
else:
validated[unique_id] = result[CONF_DATA]
if invalid:
async_create_entity_validation_issue(self.hass, platform, invalid)
return validated
async def create_entity(
self, platform: Platform, data: dict[str, Any]
) -> str | None:
@@ -408,19 +408,21 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
probatio.Optional(CONF_GA_COLOR_TEMP): GASelector(
write_required=True, dpt=ColorTempModes
),
probatio.Required(
CONF_COLOR_TEMP_MIN, default=2700
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, max=10000, step=1, unit_of_measurement="K"
)
probatio.Required(CONF_COLOR_TEMP_MIN, default=2700): AllSerializeFirst(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, max=10000, step=1, unit_of_measurement="K"
)
),
probatio.Coerce(int),
),
probatio.Required(
CONF_COLOR_TEMP_MAX, default=6000
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, max=10000, step=1, unit_of_measurement="K"
)
probatio.Required(CONF_COLOR_TEMP_MAX, default=6000): AllSerializeFirst(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1, max=10000, step=1, unit_of_measurement="K"
)
),
probatio.Coerce(int),
),
probatio.Optional(CONF_COLOR): GroupSelect(
GroupSelectOption(
@@ -1321,6 +1321,10 @@
},
"title": "KNX Data Secure telegrams can't be decrypted"
},
"entity_validation_error": {
"description": "The stored configuration of the following KNX {platform} entities is invalid, so they were not set up:\n\n{entities}\n\nCorrect or delete them. Check the logs for details.",
"title": "Invalid KNX entity configuration"
},
"telegram_storage_error": {
"description": "The configured KNX telegram storage backend failed to initialize. As a result, KNX telegrams are currently not being stored. Check the logs for details on the error and ensure your database is accessible.",
"title": "KNX telegram storage error"
+1 -1
View File
@@ -65,7 +65,7 @@ async def async_setup_entry(
KnxYamlSwitch(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH):
if ui_config := knx_module.config_store.get_entity_configs(Platform.SWITCH):
entities.extend(
KnxUiSwitch(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -66,7 +66,7 @@ async def async_setup_entry(
KnxYamlText(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.TEXT):
if ui_config := knx_module.config_store.get_entity_configs(Platform.TEXT):
entities.extend(
KnxUiText(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -59,7 +59,7 @@ async def async_setup_entry(
KnxYamlTime(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.TIME):
if ui_config := knx_module.config_store.get_entity_configs(Platform.TIME):
entities.extend(
KnxUiTime(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
+1 -1
View File
@@ -72,7 +72,7 @@ async def async_setup_entry(
KnxYamlWeather(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.WEATHER):
if ui_config := knx_module.config_store.get_entity_configs(Platform.WEATHER):
entities.extend(
KnxUiWeather(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
@@ -34,7 +34,7 @@
"passive": []
},
"respond_to_read": false,
"sync_state": false
"sync_state": true
}
}
}
@@ -34,7 +34,7 @@
"passive": []
},
"respond_to_read": false,
"sync_state": false
"sync_state": true
}
}
}
@@ -0,0 +1,64 @@
{
"version": 2,
"minor_version": 4,
"key": "knx/config_store.json",
"data": {
"entities": {
"switch": {
"knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7": {
"entity": {
"name": "valid",
"device_info": null,
"entity_category": "config"
},
"knx": {
"ga_switch": {
"write": "1/1/45",
"state": "1/0/45",
"passive": []
},
"invert": false,
"sync_state": true,
"respond_to_read": false
}
},
"knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB": {
"entity": {
"name": "invalid group address",
"device_info": null,
"entity_category": null
},
"knx": {
"ga_switch": {
"write": "not a group address",
"state": null,
"passive": []
},
"invert": false,
"sync_state": true,
"respond_to_read": false
}
}
},
"light": {
"knx_es_01J85ZKTFHSZNG4X9DYBE592TF": {
"entity": {
"name": "missing defaults",
"device_info": null,
"entity_category": null
},
"knx": {
"ga_switch": {
"write": "1/1/21",
"state": "1/0/21",
"passive": []
},
"sync_state": true
}
}
}
},
"expose": {},
"time_server": {}
}
}
@@ -34,7 +34,7 @@
"passive": []
},
"respond_to_read": false,
"sync_state": false
"sync_state": true
}
}
}
+87 -2
View File
@@ -4,12 +4,17 @@ from typing import Any
import pytest
from homeassistant.components.knx.const import (
DOMAIN,
KNX_MODULE_KEY,
REPAIR_ISSUE_ENTITY_VALIDATION_ERROR,
)
from homeassistant.components.knx.storage.config_store import (
STORAGE_KEY as KNX_CONFIG_STORAGE_KEY,
)
from homeassistant.const import Platform
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from . import KnxEntityGenerator
from .conftest import KNXTestKit
@@ -605,6 +610,86 @@ async def test_delete_expose_error(
)
##################
# STORE VALIDATION
##################
VALID_SWITCH_UID = "knx_es_01JWDFHP1ZG6NT62BX6ENR3MG7"
INVALID_SWITCH_UID = "knx_es_01JWDFKBG3PYPPRQDJZ3N3PMCB"
LIGHT_UID = "knx_es_01J85ZKTFHSZNG4X9DYBE592TF"
async def test_load_skips_invalid_entity_config(
hass: HomeAssistant,
knx: KNXTestKit,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test an invalid stored config is skipped without failing its platform."""
await knx.setup_integration(
config_store_fixture="config_store_invalid.json", state_updater=False
)
assert entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, VALID_SWITCH_UID
)
assert (
entity_registry.async_get_entity_id(Platform.SWITCH, DOMAIN, INVALID_SWITCH_UID)
is None
)
issue = issue_registry.async_get_issue(
DOMAIN, f"{REPAIR_ISSUE_ENTITY_VALIDATION_ERROR}_{Platform.SWITCH}"
)
assert issue is not None
assert issue.severity is ir.IssueSeverity.ERROR
assert issue.translation_placeholders == {
"platform": Platform.SWITCH,
"entities": f"- {INVALID_SWITCH_UID}",
}
async def test_load_applies_schema_defaults_and_coercion(
hass: HomeAssistant,
knx: KNXTestKit,
entity_registry: er.EntityRegistry,
) -> None:
"""Test stored configs are normalized on load.
The light in the fixture predates `color_temp_min` / `color_temp_max`, which
`KnxUiLight.__init__` reads by direct key access, and the switch stores
`entity_category` as a plain string.
"""
await knx.setup_integration(
config_store_fixture="config_store_invalid.json", state_updater=False
)
assert hass.states.get("light.missing_defaults") is not None
config_store = hass.data[KNX_MODULE_KEY].config_store
light_config = config_store.get_entity_configs(Platform.LIGHT)[LIGHT_UID][DOMAIN]
assert light_config["color_temp_min"] == 2700
assert light_config["color_temp_max"] == 6000
switch_id = entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, VALID_SWITCH_UID
)
assert entity_registry.async_get(switch_id).entity_category is EntityCategory.CONFIG
async def test_load_valid_store_creates_no_issue(
hass: HomeAssistant,
knx: KNXTestKit,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test a valid store doesn't raise a repair issue."""
await knx.setup_integration(
config_store_fixture="config_store_light_switch.json", state_updater=False
)
assert not [
issue
for issue in issue_registry.issues.values()
if issue.issue_id.startswith(REPAIR_ISSUE_ENTITY_VALIDATION_ERROR)
]
###########
# MIGRATION
###########
+1 -1
View File
@@ -281,7 +281,7 @@ async def test_number_ui_load(knx: KNXTestKit) -> None:
)
knx.assert_state(
"number.test_options",
"3000",
"3000.0", # `min`, `max` and `step` are floats after validation
unit_of_measurement="kW",
device_class="power",
min=3000,