diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index cbecb878e12..d8ef4319589 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -33,6 +33,7 @@ from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_ENTITY, @@ -223,7 +224,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value: color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - color_dpt = conf.get_dpt(CONF_GA_COLOR) + color_dpt = conf.get_dpt(CONF_COLOR, CONF_GA_COLOR) return XknxLight( xknx, @@ -232,59 +233,65 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight group_address_switch_state=conf.get_state_and_passive(CONF_GA_SWITCH), group_address_brightness=conf.get_write(CONF_GA_BRIGHTNESS), group_address_brightness_state=conf.get_state_and_passive(CONF_GA_BRIGHTNESS), - group_address_color=conf.get_write(CONF_GA_COLOR) + group_address_color=conf.get_write(CONF_COLOR, CONF_GA_COLOR) if color_dpt == LightColorMode.RGB else None, - group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR) + group_address_color_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) if color_dpt == LightColorMode.RGB else None, - group_address_rgbw=conf.get_write(CONF_GA_COLOR) + group_address_rgbw=conf.get_write(CONF_COLOR, CONF_GA_COLOR) if color_dpt == LightColorMode.RGBW else None, - group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR) + group_address_rgbw_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_COLOR) if color_dpt == LightColorMode.RGBW else None, - group_address_hue=conf.get_write(CONF_GA_HUE), - group_address_hue_state=conf.get_state_and_passive(CONF_GA_HUE), - group_address_saturation=conf.get_write(CONF_GA_SATURATION), - group_address_saturation_state=conf.get_state_and_passive(CONF_GA_SATURATION), - group_address_xyy_color=conf.get_write(CONF_GA_COLOR) + group_address_hue=conf.get_write(CONF_COLOR, CONF_GA_HUE), + group_address_hue_state=conf.get_state_and_passive(CONF_COLOR, CONF_GA_HUE), + group_address_saturation=conf.get_write(CONF_COLOR, CONF_GA_SATURATION), + group_address_saturation_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_SATURATION + ), + group_address_xyy_color=conf.get_write(CONF_COLOR, CONF_GA_COLOR) if color_dpt == LightColorMode.XYY else None, - group_address_xyy_color_state=conf.get_write(CONF_GA_COLOR) + group_address_xyy_color_state=conf.get_write(CONF_COLOR, CONF_GA_COLOR) if color_dpt == LightColorMode.XYY else None, group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temp, group_address_color_temperature_state=group_address_color_temp_state, - group_address_switch_red=conf.get_write(CONF_GA_RED_SWITCH), - group_address_switch_red_state=conf.get_state_and_passive(CONF_GA_RED_SWITCH), - group_address_brightness_red=conf.get_write(CONF_GA_RED_BRIGHTNESS), - group_address_brightness_red_state=conf.get_state_and_passive( - CONF_GA_RED_BRIGHTNESS + group_address_switch_red=conf.get_write(CONF_COLOR, CONF_GA_RED_SWITCH), + group_address_switch_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_SWITCH ), - group_address_switch_green=conf.get_write(CONF_GA_GREEN_SWITCH), + group_address_brightness_red=conf.get_write(CONF_COLOR, CONF_GA_RED_BRIGHTNESS), + group_address_brightness_red_state=conf.get_state_and_passive( + CONF_COLOR, CONF_GA_RED_BRIGHTNESS + ), + group_address_switch_green=conf.get_write(CONF_COLOR, CONF_GA_GREEN_SWITCH), group_address_switch_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_SWITCH + CONF_COLOR, CONF_GA_GREEN_SWITCH ), group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS), group_address_brightness_green_state=conf.get_state_and_passive( - CONF_GA_GREEN_BRIGHTNESS + CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS ), group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH), group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH), group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS), group_address_brightness_blue_state=conf.get_state_and_passive( - CONF_GA_BLUE_BRIGHTNESS + CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS ), - group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH), + group_address_switch_white=conf.get_write(CONF_COLOR, CONF_GA_WHITE_SWITCH), group_address_switch_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_SWITCH + CONF_COLOR, CONF_GA_WHITE_SWITCH + ), + group_address_brightness_white=conf.get_write( + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), - group_address_brightness_white=conf.get_write(CONF_GA_WHITE_BRIGHTNESS), group_address_brightness_white_state=conf.get_state_and_passive( - CONF_GA_WHITE_BRIGHTNESS + CONF_COLOR, CONF_GA_WHITE_BRIGHTNESS ), color_temperature_type=color_temperature_type, min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], diff --git a/homeassistant/components/knx/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 2899448a128..d6a5a3eac28 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -13,10 +13,11 @@ from homeassistant.util.ulid import ulid_now from ..const import DOMAIN from .const import CONF_DATA +from .migration import migrate_1_to_2 _LOGGER = logging.getLogger(__name__) -STORAGE_VERSION: Final = 1 +STORAGE_VERSION: Final = 2 STORAGE_KEY: Final = f"{DOMAIN}/config_store.json" type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration @@ -45,6 +46,20 @@ class PlatformControllerBase(ABC): """Update an existing entities configuration.""" +class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]): + """Storage handler for KNXConfigStore.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + # version 2 introduced in 2025.7 + migrate_1_to_2(old_data) + + return old_data + + class KNXConfigStore: """Manage KNX config store data.""" @@ -56,7 +71,7 @@ class KNXConfigStore: """Initialize config store.""" self.hass = hass self.config_entry = config_entry - self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _KNXConfigStoreStorage(hass, STORAGE_VERSION, STORAGE_KEY) self.data = KNXConfigStoreModel(entities={}) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 7cae0e9bbf6..78cd38c9d00 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -2,6 +2,7 @@ from typing import Final +# Common CONF_DATA: Final = "data" CONF_ENTITY: Final = "entity" CONF_DEVICE_INFO: Final = "device_info" @@ -12,10 +13,22 @@ CONF_DPT: Final = "dpt" CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" -CONF_GA_COLOR_TEMP: Final = "ga_color_temp" + +# Cover +CONF_GA_UP_DOWN: Final = "ga_up_down" +CONF_GA_STOP: Final = "ga_stop" +CONF_GA_STEP: Final = "ga_step" +CONF_GA_POSITION_SET: Final = "ga_position_set" +CONF_GA_POSITION_STATE: Final = "ga_position_state" +CONF_GA_ANGLE: Final = "ga_angle" + +# Light CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MAX: Final = "color_temp_max" CONF_GA_BRIGHTNESS: Final = "ga_brightness" +CONF_GA_COLOR_TEMP: Final = "ga_color_temp" +# Light/color +CONF_COLOR: Final = "color" CONF_GA_COLOR: Final = "ga_color" CONF_GA_RED_BRIGHTNESS: Final = "ga_red_brightness" CONF_GA_RED_SWITCH: Final = "ga_red_switch" @@ -27,9 +40,3 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness" CONF_GA_WHITE_SWITCH: Final = "ga_white_switch" CONF_GA_HUE: Final = "ga_hue" CONF_GA_SATURATION: Final = "ga_saturation" -CONF_GA_UP_DOWN: Final = "ga_up_down" -CONF_GA_STOP: Final = "ga_stop" -CONF_GA_STEP: Final = "ga_step" -CONF_GA_POSITION_SET: Final = "ga_position_set" -CONF_GA_POSITION_STATE: Final = "ga_position_state" -CONF_GA_ANGLE: Final = "ga_angle" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 85bcbd1809f..8f0fa801483 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -29,6 +29,7 @@ from ..const import ( ) from ..validation import sync_state_validator from .const import ( + CONF_COLOR, CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, CONF_DATA, @@ -59,7 +60,7 @@ from .const import ( CONF_GA_WHITE_SWITCH, CONF_GA_WRITE, ) -from .knx_selector import GASelector +from .knx_selector import GASelector, GroupSelect BASE_ENTITY_SCHEMA = vol.All( { @@ -134,16 +135,14 @@ COVER_SCHEMA = vol.Schema( vol.Required(DOMAIN): vol.All( vol.Schema( { - **optional_ga_schema(CONF_GA_UP_DOWN, GASelector(state=False)), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_STOP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_STEP, GASelector(state=False)), - **optional_ga_schema(CONF_GA_POSITION_SET, GASelector(state=False)), - **optional_ga_schema( - CONF_GA_POSITION_STATE, GASelector(write=False) - ), + vol.Optional(CONF_GA_STOP): GASelector(state=False), + vol.Optional(CONF_GA_STEP): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), + vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), - **optional_ga_schema(CONF_GA_ANGLE, GASelector()), + vol.Optional(CONF_GA_ANGLE): GASelector(), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), vol.Optional( CoverConf.TRAVELLING_TIME_DOWN, default=25 @@ -208,72 +207,111 @@ class LightColorModeSchema(StrEnum): HSV = "hsv" -_LIGHT_COLOR_MODE_SCHEMA = "_light_color_mode_schema" +_hs_color_inclusion_msg = ( + "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" +) -_COMMON_LIGHT_SCHEMA = vol.Schema( - { - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - **optional_ga_schema( - CONF_GA_COLOR_TEMP, GASelector(write_required=True, dpt=ColorTempModes) + +LIGHT_KNX_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + vol.Optional(CONF_GA_COLOR_TEMP): GASelector( + write_required=True, dpt=ColorTempModes + ), + vol.Optional(CONF_COLOR): GroupSelect( + vol.Schema( + { + vol.Optional(CONF_GA_COLOR): GASelector( + write_required=True, dpt=LightColorMode + ) + } + ), + vol.Schema( + { + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_RED_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( + write_required=False + ), + vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( + write_required=False + ), + vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( + write_required=True + ), + vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( + write_required=False + ), + } + ), + vol.Schema( + { + vol.Required(CONF_GA_HUE): GASelector(write_required=True), + vol.Required(CONF_GA_SATURATION): GASelector( + write_required=True + ), + } + ), + # msg="error in `color` config", + ), + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( + vol.Coerce(int), vol.Range(min=1) + ), + } + ), + vol.Any( + vol.Schema( + {vol.Required(CONF_GA_SWITCH): object}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MIN, default=2700): vol.All( - vol.Coerce(int), vol.Range(min=1) + vol.Schema( # brightness addresses are required in INDIVIDUAL_COLOR_SCHEMA + {vol.Required(CONF_COLOR): {vol.Required(CONF_GA_RED_BRIGHTNESS): object}}, + extra=vol.ALLOW_EXTRA, ), - vol.Optional(CONF_COLOR_TEMP_MAX, default=6000): vol.All( - vol.Coerce(int), vol.Range(min=1) + msg="either 'address' or 'individual_colors' is required", + ), + vol.Any( + vol.Schema( # 'brightness' is non-optional for hs-color + { + vol.Required(CONF_GA_BRIGHTNESS, msg=_hs_color_inclusion_msg): object, + vol.Required(CONF_COLOR): { + vol.Required(CONF_GA_HUE, msg=_hs_color_inclusion_msg): object, + vol.Required( + CONF_GA_SATURATION, msg=_hs_color_inclusion_msg + ): object, + }, + }, + extra=vol.ALLOW_EXTRA, ), - }, - extra=vol.REMOVE_EXTRA, -) - -_DEFAULT_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.DEFAULT.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema( - CONF_GA_COLOR, - GASelector(write_required=True, dpt=LightColorMode), + vol.Schema( # hs-colors not used + { + vol.Optional(CONF_COLOR): { + vol.Optional(CONF_GA_HUE): None, + vol.Optional(CONF_GA_SATURATION): None, + }, + }, + extra=vol.ALLOW_EXTRA, ), - } + msg=_hs_color_inclusion_msg, + ), ) -_INDIVIDUAL_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.INDIVIDUAL.value, - **optional_ga_schema(CONF_GA_SWITCH, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_BRIGHTNESS, GASelector(write_required=True)), - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_RED_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_GREEN_SWITCH, GASelector(write_required=False)), - vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(write_required=True), - **optional_ga_schema(CONF_GA_BLUE_SWITCH, GASelector(write_required=False)), - **optional_ga_schema(CONF_GA_WHITE_BRIGHTNESS, GASelector(write_required=True)), - **optional_ga_schema(CONF_GA_WHITE_SWITCH, GASelector(write_required=False)), - } -) - -_HSV_LIGHT_SCHEMA = _COMMON_LIGHT_SCHEMA.extend( - { - vol.Required(_LIGHT_COLOR_MODE_SCHEMA): LightColorModeSchema.HSV.value, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Required(CONF_GA_BRIGHTNESS): GASelector(write_required=True), - vol.Required(CONF_GA_HUE): GASelector(write_required=True), - vol.Required(CONF_GA_SATURATION): GASelector(write_required=True), - } -) - - -LIGHT_KNX_SCHEMA = cv.key_value_schemas( - _LIGHT_COLOR_MODE_SCHEMA, - default_schema=_DEFAULT_LIGHT_SCHEMA, - value_schemas={ - LightColorModeSchema.DEFAULT: _DEFAULT_LIGHT_SCHEMA, - LightColorModeSchema.INDIVIDUAL: _INDIVIDUAL_LIGHT_SCHEMA, - LightColorModeSchema.HSV: _HSV_LIGHT_SCHEMA, - }, -) LIGHT_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index a1510dbb384..986345e9d1e 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -1,5 +1,6 @@ """Selectors for KNX.""" +from collections.abc import Hashable, Iterable from enum import Enum from typing import Any @@ -9,6 +10,31 @@ from ..validation import ga_validator, maybe_ga_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE +class GroupSelect(vol.Any): + """Use the first validated value. + + This is a version of vol.Any with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + def _exec(self, funcs: Iterable, v: Any, path: list[Hashable] | None = None) -> Any: + """Execute the validation functions.""" + errors = [] + for func in funcs: + try: + if path is None: + return func(v) + return func(path, v) + except vol.Invalid as e: + errors.append(e) + if errors: + raise next( + (err for err in errors if "extra keys not allowed" not in err.msg), + errors[0], + ) + raise vol.AnyInvalid(self.msg or "no valid value found", path=path) + + class GASelector: """Selector for a KNX group address structure.""" diff --git a/homeassistant/components/knx/storage/migration.py b/homeassistant/components/knx/storage/migration.py new file mode 100644 index 00000000000..c4377122ed1 --- /dev/null +++ b/homeassistant/components/knx/storage/migration.py @@ -0,0 +1,42 @@ +"""Migration functions for KNX config store schema.""" + +from typing import Any + +from homeassistant.const import Platform + +from . import const as store_const + + +def migrate_1_to_2(data: dict[str, Any]) -> None: + """Migrate from schema 1 to schema 2.""" + if lights := data.get("entities", {}).get(Platform.LIGHT): + for light in lights.values(): + _migrate_light_schema_1_to_2(light["knx"]) + + +def _migrate_light_schema_1_to_2(light_knx_data: dict[str, Any]) -> None: + """Migrate light color mode schema.""" + # Remove no more needed helper data from schema + light_knx_data.pop("_light_color_mode_schema", None) + + # Move color related group addresses to new "color" key + color = {} + for color_key in ( + # optional / required and exclusive keys are the same in old and new schema + store_const.CONF_GA_COLOR, + store_const.CONF_GA_HUE, + store_const.CONF_GA_SATURATION, + store_const.CONF_GA_RED_BRIGHTNESS, + store_const.CONF_GA_RED_SWITCH, + store_const.CONF_GA_GREEN_BRIGHTNESS, + store_const.CONF_GA_GREEN_SWITCH, + store_const.CONF_GA_BLUE_BRIGHTNESS, + store_const.CONF_GA_BLUE_SWITCH, + store_const.CONF_GA_WHITE_BRIGHTNESS, + store_const.CONF_GA_WHITE_SWITCH, + ): + if color_key in light_knx_data: + color[color_key] = light_knx_data.pop(color_key) + + if color: + light_knx_data[store_const.CONF_COLOR] = color diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index fb0246763a4..5edf150ef4f 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1182,7 +1182,6 @@ async def test_light_ui_create( entity_data={"name": "test"}, knx_data={ "ga_switch": {"write": "1/1/1", "state": "2/2/2"}, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1223,7 +1222,6 @@ async def test_light_ui_color_temp( "write": "3/3/3", "dpt": color_temp_mode, }, - "_light_color_mode_schema": "default", "sync_state": True, }, ) @@ -1257,7 +1255,6 @@ async def test_light_ui_multi_mode( knx_data={ "color_temp_min": 2700, "color_temp_max": 6000, - "_light_color_mode_schema": "default", "ga_switch": { "write": "1/1/1", "passive": [], @@ -1275,11 +1272,13 @@ async def test_light_ui_multi_mode( "state": "0/6/3", "passive": [], }, - "ga_color": { - "write": "0/6/4", - "dpt": "251.600", - "state": "0/6/5", - "passive": [], + "color": { + "ga_color": { + "write": "0/6/4", + "dpt": "251.600", + "state": "0/6/5", + "passive": [], + }, }, }, )