diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 6c41a7d29e7..fe0dbf31b6b 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -27,7 +27,6 @@ from ..const import ( ColorTempModes, CoverConf, ) -from ..validation import sync_state_validator from .const import ( CONF_COLOR, CONF_COLOR_TEMP_MAX, @@ -57,7 +56,14 @@ from .const import ( CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, ) -from .knx_selector import GASelector, GroupSelect +from .knx_selector import ( + AllSerializeFirst, + GASelector, + GroupSelect, + GroupSelectOption, + KNXSectionFlat, + SyncStateSelector, +) BASE_ENTITY_SCHEMA = vol.All( { @@ -85,86 +91,86 @@ BASE_ENTITY_SCHEMA = vol.All( ) -BINARY_SENSOR_SCHEMA = vol.Schema( +BINARY_SENSOR_KNX_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): { - vol.Required(CONF_GA_SENSOR): GASelector(write=False, state_required=True), - vol.Required(CONF_RESPOND_TO_READ, default=False): bool, - vol.Required(CONF_SYNC_STATE, default=True): sync_state_validator, - vol.Optional(CONF_INVERT): selector.BooleanSelector(), - vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), - vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=10, step=0.1, unit_of_measurement="s" - ) - ), - vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=600, step=0.1, unit_of_measurement="s" - ) - ), - }, - } + "section_binary_sensor": KNXSectionFlat(), + vol.Required(CONF_GA_SENSOR): GASelector( + write=False, state_required=True, valid_dpt="1" + ), + vol.Optional(CONF_INVERT): selector.BooleanSelector(), + "section_advanced_options": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_IGNORE_INTERNAL_STATE): selector.BooleanSelector(), + vol.Optional(CONF_CONTEXT_TIMEOUT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=10, step=0.1, unit_of_measurement="s" + ) + ), + vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=600, step=0.1, unit_of_measurement="s" + ) + ), + vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, ) -COVER_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): vol.All( - vol.Schema( - { - vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), - vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), - 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(), - vol.Optional(CONF_GA_ANGLE): GASelector(), - vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), - vol.Optional( - CoverConf.TRAVELLING_TIME_DOWN, default=25 - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=1000, step=0.1, unit_of_measurement="s" - ) - ), - vol.Optional( - CoverConf.TRAVELLING_TIME_UP, default=25 - ): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=1000, step=0.1, unit_of_measurement="s" - ) - ), - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - }, - extra=vol.REMOVE_EXTRA, +COVER_KNX_SCHEMA = AllSerializeFirst( + vol.Schema( + { + "section_binary_control": KNXSectionFlat(), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), + vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), + "section_stop_control": KNXSectionFlat(), + vol.Optional(CONF_GA_STOP): GASelector(state=False), + vol.Optional(CONF_GA_STEP): GASelector(state=False), + "section_position_control": KNXSectionFlat(collapsible=True), + 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(), + "section_tilt_control": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_ANGLE): GASelector(), + vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), + "section_travel_time": KNXSectionFlat(), + vol.Optional( + CoverConf.TRAVELLING_TIME_UP, default=25 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=1000, step=0.1, unit_of_measurement="s" + ) ), - vol.Any( - vol.Schema( - { - vol.Required(CONF_GA_UP_DOWN): GASelector( - state=False, write_required=True - ) - }, - extra=vol.ALLOW_EXTRA, - ), - vol.Schema( - { - vol.Required(CONF_GA_POSITION_SET): GASelector( - state=False, write_required=True - ) - }, - extra=vol.ALLOW_EXTRA, - ), - msg=( - "At least one of 'Up/Down control' or" - " 'Position - Set position' is required." - ), + vol.Optional( + CoverConf.TRAVELLING_TIME_DOWN, default=25 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=1000, step=0.1, unit_of_measurement="s" + ) ), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, + extra=vol.REMOVE_EXTRA, + ), + vol.Any( + vol.Schema( + { + vol.Required(CONF_GA_UP_DOWN): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, ), - } + vol.Schema( + { + vol.Required(CONF_GA_POSITION_SET): GASelector( + state=False, write_required=True + ) + }, + extra=vol.ALLOW_EXTRA, + ), + msg=( + "At least one of 'Open/Close control' or" + " 'Position - Set position' is required." + ), + ), ) @@ -177,81 +183,91 @@ class LightColorMode(StrEnum): XYY = "242.600" -@unique -class LightColorModeSchema(StrEnum): - """Enum for light color mode.""" - - DEFAULT = "default" - INDIVIDUAL = "individual" - HSV = "hsv" - - _hs_color_inclusion_msg = ( "'Hue', 'Saturation' and 'Brightness' addresses are required for HSV configuration" ) -LIGHT_KNX_SCHEMA = vol.All( +LIGHT_KNX_SCHEMA = AllSerializeFirst( vol.Schema( { - vol.Optional(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Optional(CONF_GA_BRIGHTNESS): GASelector(write_required=True), + "section_switch": KNXSectionFlat(), + vol.Optional(CONF_GA_SWITCH): GASelector( + write_required=True, valid_dpt="1" + ), + "section_brightness": KNXSectionFlat(), + vol.Optional(CONF_GA_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" + ), + "section_color_temp": KNXSectionFlat(collapsible=True), vol.Optional(CONF_GA_COLOR_TEMP): GASelector( write_required=True, dpt=ColorTempModes ), + vol.Required(CONF_COLOR_TEMP_MIN, default=2700): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=10000, step=1, unit_of_measurement="K" + ) + ), + vol.Required(CONF_COLOR_TEMP_MAX, default=6000): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=10000, step=1, unit_of_measurement="K" + ) + ), vol.Optional(CONF_COLOR): GroupSelect( - vol.Schema( - { + GroupSelectOption( + translation_key="single_address", + schema={ vol.Optional(CONF_GA_COLOR): GASelector( write_required=True, dpt=LightColorMode ) - } + }, ), - vol.Schema( - { - vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( - write_required=True - ), + GroupSelectOption( + translation_key="individual_addresses", + schema={ + "section_red": KNXSectionFlat(), vol.Optional(CONF_GA_RED_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" + ), + vol.Required(CONF_GA_RED_BRIGHTNESS): GASelector( + write_required=True, valid_dpt="5.001" + ), + "section_green": KNXSectionFlat(), + vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( + write_required=False, valid_dpt="1" ), vol.Required(CONF_GA_GREEN_BRIGHTNESS): GASelector( - write_required=True - ), - vol.Optional(CONF_GA_GREEN_SWITCH): GASelector( - write_required=False + write_required=True, valid_dpt="5.001" ), + "section_blue": KNXSectionFlat(), vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector( - write_required=True + write_required=True, valid_dpt="5.001" ), vol.Optional(CONF_GA_BLUE_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" ), + "section_white": KNXSectionFlat(), vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector( - write_required=True + write_required=True, valid_dpt="5.001" ), vol.Optional(CONF_GA_WHITE_SWITCH): GASelector( - write_required=False + write_required=False, valid_dpt="1" ), - } + }, ), - vol.Schema( - { - vol.Required(CONF_GA_HUE): GASelector(write_required=True), + GroupSelectOption( + translation_key="hsv_addresses", + schema={ + vol.Required(CONF_GA_HUE): GASelector( + write_required=True, valid_dpt="5.001" + ), vol.Required(CONF_GA_SATURATION): GASelector( - write_required=True + write_required=True, valid_dpt="5.001" ), - } + }, ), - # 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.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), } ), vol.Any( @@ -291,26 +307,22 @@ LIGHT_KNX_SCHEMA = vol.All( ), ) - -LIGHT_SCHEMA = vol.Schema( +SWITCH_KNX_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): LIGHT_KNX_SCHEMA, - } + "section_switch": KNXSectionFlat(), + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), + vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, ) - -SWITCH_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, - vol.Required(DOMAIN): { - vol.Optional(CONF_INVERT, default=False): bool, - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), - vol.Optional(CONF_RESPOND_TO_READ, default=False): bool, - vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, - }, - } -) +KNX_SCHEMA_FOR_PLATFORM = { + Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA, + Platform.COVER: COVER_KNX_SCHEMA, + Platform.LIGHT: LIGHT_KNX_SCHEMA, + Platform.SWITCH: SWITCH_KNX_SCHEMA, +} ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( vol.Schema( @@ -326,18 +338,16 @@ ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All( cv.key_value_schemas( CONF_PLATFORM, { - Platform.BINARY_SENSOR: vol.Schema( - {vol.Required(CONF_DATA): BINARY_SENSOR_SCHEMA}, extra=vol.ALLOW_EXTRA - ), - Platform.COVER: vol.Schema( - {vol.Required(CONF_DATA): COVER_SCHEMA}, extra=vol.ALLOW_EXTRA - ), - Platform.LIGHT: vol.Schema( - {vol.Required(CONF_DATA): LIGHT_SCHEMA}, extra=vol.ALLOW_EXTRA - ), - Platform.SWITCH: vol.Schema( - {vol.Required(CONF_DATA): SWITCH_SCHEMA}, extra=vol.ALLOW_EXTRA - ), + platform: vol.Schema( + { + vol.Required(CONF_DATA): { + vol.Required(CONF_ENTITY): BASE_ENTITY_SCHEMA, + vol.Required(DOMAIN): knx_schema, + }, + }, + extra=vol.ALLOW_EXTRA, + ) + for platform, knx_schema in KNX_SCHEMA_FOR_PLATFORM.items() }, ), ) diff --git a/homeassistant/components/knx/storage/knx_selector.py b/homeassistant/components/knx/storage/knx_selector.py index fe909f1fd0a..8bba12633c8 100644 --- a/homeassistant/components/knx/storage/knx_selector.py +++ b/homeassistant/components/knx/storage/knx_selector.py @@ -6,11 +6,102 @@ from typing import Any import voluptuous as vol -from ..validation import ga_validator, maybe_ga_validator +from ..validation import ga_validator, maybe_ga_validator, sync_state_validator from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE -class GroupSelect(vol.Any): +class AllSerializeFirst(vol.All): + """Use the first validated value for serialization. + + This is a version of vol.All with custom error handling to + show proper invalid markers for sub-schema items in the UI. + """ + + +class KNXSelectorBase: + """Base class for KNX selectors supporting optional nested schemas.""" + + schema: vol.Schema | vol.Any | vol.All + selector_type: str + # mark if self.schema should be serialized to `schema` key + serialize_subschema: bool = False + + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + return self.schema(data) + + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + # don't use "name", "default", "optional" or "required" in base output + # as it will be overwritten by the parent keys attributes + # "schema" will be overwritten by knx serializer if `self.serialize_subschema` is True + raise NotImplementedError("Subclasses must implement this method.") + + +class KNXSectionFlat(KNXSelectorBase): + """Generate a schema-neutral section with title and description for the following siblings.""" + + selector_type = "knx_section_flat" + schema = vol.Schema(None) + + def __init__( + self, + collapsible: bool = False, + ) -> None: + """Initialize the section.""" + self.collapsible = collapsible + + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "collapsible": self.collapsible, + } + + +class KNXSection(KNXSelectorBase): + """Configuration groups similar to DataEntryFlow sections but with more options.""" + + selector_type = "knx_section" + serialize_subschema = True + + def __init__( + self, + schema: dict[str | vol.Marker, vol.Schemable], + collapsible: bool = True, + ) -> None: + """Initialize the section.""" + self.collapsible = collapsible + self.schema = vol.Schema(schema) + + def serialize(self) -> dict[str, Any]: + """Serialize the section to a dictionary.""" + return { + "type": self.selector_type, + "collapsible": self.collapsible, + } + + +class GroupSelectOption(KNXSelectorBase): + """Schema for group select options.""" + + selector_type = "knx_group_select_option" + serialize_subschema: bool = True + + def __init__(self, schema: vol.Schemable, translation_key: str) -> None: + """Initialize the group select option schema.""" + self.translation_key = translation_key + self.schema = vol.Schema(schema) + + def serialize(self) -> dict[str, Any]: + """Serialize the group select option to a dictionary.""" + return { + "type": self.selector_type, + "translation_key": self.translation_key, + } + + +class GroupSelectSchema(vol.Any): """Use the first validated value. This is a version of vol.Any with custom error handling to @@ -35,10 +126,33 @@ class GroupSelect(vol.Any): raise vol.AnyInvalid(self.msg or "no valid value found", path=path) -class GASelector: +class GroupSelect(KNXSelectorBase): + """Selector for group select options.""" + + selector_type = "knx_group_select" + serialize_subschema = True + + def __init__( + self, + *options: GroupSelectOption, + collapsible: bool = True, + ) -> None: + """Initialize the group select selector.""" + self.collapsible = collapsible + self.schema = GroupSelectSchema(*options) + + def serialize(self) -> dict[str, Any]: + """Serialize the group select to a dictionary.""" + return { + "type": self.selector_type, + "collapsible": self.collapsible, + } + + +class GASelector(KNXSelectorBase): """Selector for a KNX group address structure.""" - schema: vol.Schema + selector_type = "knx_group_address" def __init__( self, @@ -48,6 +162,7 @@ class GASelector: write_required: bool = False, state_required: bool = False, dpt: type[Enum] | None = None, + valid_dpt: str | Iterable[str] | None = None, ) -> None: """Initialize the group address selector.""" self.write = write @@ -56,12 +171,43 @@ class GASelector: self.write_required = write_required self.state_required = state_required self.dpt = dpt + # valid_dpt is used in frontend to filter dropdown menu - no validation is done + self.valid_dpt = (valid_dpt,) if isinstance(valid_dpt, str) else valid_dpt self.schema = self.build_schema() - def __call__(self, data: Any) -> Any: - """Validate the passed data.""" - return self.schema(data) + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + + def dpt_to_dict(dpt: str) -> dict[str, int | None]: + """Convert a DPT string to a dictionary.""" + dpt_num = dpt.split(".") + return { + "main": int(dpt_num[0]), + "sub": int(dpt_num[1]) if len(dpt_num) > 1 else None, + } + + options: dict[str, Any] = { + "write": {"required": self.write_required} if self.write else False, + "state": {"required": self.state_required} if self.state else False, + "passive": self.passive, + } + if self.dpt is not None: + options["dptSelect"] = [ + { + "value": item.value, + "translation_key": item.value.replace(".", "_"), + "dpt": dpt_to_dict(item.value), # used to filter DPTs in dropdown + } + for item in self.dpt + ] + if self.valid_dpt is not None: + options["validDPTs"] = [dpt_to_dict(dpt) for dpt in self.valid_dpt] + + return { + "type": self.selector_type, + "options": options, + } def build_schema(self) -> vol.Schema: """Create the schema based on configuration.""" @@ -118,3 +264,27 @@ class GASelector: schema[vol.Required(CONF_DPT)] = vol.In({item.value for item in self.dpt}) else: schema[vol.Remove(CONF_DPT)] = object + + +class SyncStateSelector(KNXSelectorBase): + """Selector for knx sync state validation.""" + + schema = vol.Schema(sync_state_validator) + selector_type = "knx_sync_state" + + def __init__(self, allow_false: bool = False) -> None: + """Initialize the sync state validator.""" + self.allow_false = allow_false + + def serialize(self) -> dict[str, Any]: + """Serialize the selector to a dictionary.""" + return { + "type": self.selector_type, + "allow_false": self.allow_false, + } + + def __call__(self, data: Any) -> Any: + """Validate the passed data.""" + if not self.allow_false and not data: + raise vol.Invalid(f"Sync state cannot be {data}") + return self.schema(data) diff --git a/homeassistant/components/knx/storage/serialize.py b/homeassistant/components/knx/storage/serialize.py new file mode 100644 index 00000000000..37ee53df40b --- /dev/null +++ b/homeassistant/components/knx/storage/serialize.py @@ -0,0 +1,42 @@ +"""Custom serializer for KNX schemas.""" + +from typing import Any + +import voluptuous as vol +from voluptuous_serialize import UNSUPPORTED, convert + +from homeassistant.const import Platform +from homeassistant.helpers import selector + +from .entity_store_schema import KNX_SCHEMA_FOR_PLATFORM +from .knx_selector import AllSerializeFirst, GroupSelectSchema, KNXSelectorBase + + +def knx_serializer( + schema: vol.Schema, +) -> dict[str, Any] | list[dict[str, Any]]: + """Serialize KNX schema.""" + if isinstance(schema, GroupSelectSchema): + return [ + convert(option, custom_serializer=knx_serializer) + for option in schema.validators + ] + if isinstance(schema, KNXSelectorBase): + result = schema.serialize() + if schema.serialize_subschema: + result["schema"] = convert(schema.schema, custom_serializer=knx_serializer) + return result + if isinstance(schema, AllSerializeFirst): + return convert(schema.validators[0], custom_serializer=knx_serializer) # type: ignore[no-any-return] + + if isinstance(schema, selector.Selector): + return schema.serialize() | {"type": "ha_selector"} + + return UNSUPPORTED # type: ignore[no-any-return] + + +def get_serialized_schema(platform: Platform) -> dict | list | None: + """Get the schema for a specific platform.""" + if knx_schema := KNX_SCHEMA_FOR_PLATFORM.get(platform): + return convert(knx_schema, custom_serializer=knx_serializer) # type: ignore[no-any-return] + return None diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 921fc2c5288..e1fbaf6aebf 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -339,5 +339,275 @@ "name": "[%key:common::action::reload%]", "description": "Reloads the KNX integration." } + }, + "config_panel": { + "entities": { + "create": { + "type_selection": { + "title": "Select entity type", + "header": "Create KNX entity" + }, + "header": "Create new entity", + "_": { + "entity": { + "title": "Entity configuration", + "description": "Home Assistant specific settings.", + "name_title": "Device and entity name", + "name_description": "Define how the entity should be named in Home Assistant.", + "device_description": "A device allows to group multiple entities. Select the device this entity belongs to or create a new one.", + "entity_label": "Entity name", + "entity_description": "Optional if a device is selected, otherwise required. If the entity is assigned to a device, the device name is used as prefix.", + "entity_category_title": "Entity category", + "entity_category_description": "Classification of a non-primary entity. Leave empty for standard behaviour." + }, + "knx": { + "title": "KNX configuration", + "knx_group_address": { + "dpt": "Datapoint type", + "send_address": "Send address", + "state_address": "State address", + "passive_addresses": "Passive addresses", + "valid_dpts": "Valid DPTs" + }, + "sync_state": { + "title": "State updater", + "description": "Actively request state updates from KNX bus for state addresses.", + "strategy": "Strategy", + "options": { + "true": "Use integration default", + "false": "Never", + "init": "Once when connection established", + "expire": "Expire after last value update", + "every": "Scheduled every" + } + } + } + }, + "binary_sensor": { + "description": "Read-only entity for binary datapoints. Window or door states etc.", + "knx": { + "section_binary_sensor": { + "title": "Binary sensor", + "description": "DPT 1 group addresses representing binary states." + }, + "invert": { + "label": "Invert", + "description": "Invert payload before processing." + }, + "section_advanced_options": { + "title": "State properties", + "description": "Properties of the binary sensor state." + }, + "ignore_internal_state": { + "label": "Force update", + "description": "Write each update to the state machine, even if the data is the same." + }, + "context_timeout": { + "label": "Context timeout", + "description": "The time in seconds between multiple identical telegram payloads would count towards an internal counter. This can be used to automate on mulit-clicks of a button. `0` to disable this feature." + }, + "reset_after": { + "label": "Reset after", + "description": "Reset back to “off” state after specified seconds." + } + } + }, + "cover": { + "description": "The KNX cover platform is used as an interface to shutter actuators.", + "knx": { + "section_binary_control": { + "title": "Open/Close control", + "description": "DPT 1 group addresses triggering full movement." + }, + "ga_up_down": { + "label": "Open/Close" + }, + "invert_updown": { + "label": "Invert", + "description": "Default is UP (0) to open a cover and DOWN (1) to close a cover. Enable this to invert the open/close commands from/to your KNX actuator." + }, + "section_stop_control": { + "title": "Stop", + "description": "DPT 1 group addresses for stopping movement." + }, + "ga_stop": { + "label": "Stop" + }, + "ga_step": { + "label": "Stepwise move" + }, + "section_position_control": { + "title": "Position", + "description": "DPT 5 group addresses for cover position." + }, + "ga_position_set": { + "label": "Set position" + }, + "ga_position_state": { + "label": "Current Position" + }, + "invert_position": { + "label": "Invert", + "description": "Invert payload before processing. Enable if KNX reports 0% as fully closed." + }, + "section_tilt_control": { + "title": "Tilt", + "description": "DPT 5 group addresses for slat tilt angle." + }, + "ga_angle": { + "label": "Tilt angle" + }, + "invert_angle": { + "label": "[%key:component::knx::config_panel::entities::create::cover::knx::invert_position::label%]", + "description": "[%key:component::knx::config_panel::entities::create::cover::knx::invert_position::description%]" + }, + "section_travel_time": { + "title": "Travel time", + "description": "Used to calculate intermediate positions of the cover while traveling." + }, + "travelling_time_up": { + "label": "Travel time for opening", + "description": "Time the cover needs to fully open in seconds." + }, + "travelling_time_down": { + "label": "Travel time for closing", + "description": "Time the cover needs to fully close in seconds." + } + } + }, + "light": { + "description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", + "knx": { + "section_switch": { + "title": "Switch", + "description": "Turn the light on/off." + }, + "ga_switch": { + "label": "Switch" + }, + "section_brightness": { + "title": "Brightness", + "description": "Control the brightness of the light." + }, + "ga_brightness": { + "label": "Brightness" + }, + "section_color_temp": { + "title": "Color temperature", + "description": "Control the color temperature of the light." + }, + "ga_color_temp": { + "label": "Color temperature", + "options": { + "5_001": "Percent", + "7_600": "Kelvin", + "9": "2-byte floating point" + } + }, + "color_temp_min": { + "label": "Warmest possible color temperature" + }, + "color_temp_max": { + "label": "Coldest possible color temperature" + }, + "color": { + "title": "Color", + "description": "Control the color of the light.", + "options": { + "single_address": { + "label": "Single address", + "description": "RGB, RGBW or XYY color controlled by a single group address." + }, + "individual_addresses": { + "label": "Individual addresses", + "description": "RGB(W) using individual state and brightness group addresses." + }, + "hsv_addresses": { + "label": "HSV", + "description": "Hue, saturation and brightness using individual group addresses." + } + }, + "ga_color": { + "label": "Color", + "options": { + "232_600": "RGB", + "242_600": "XYY", + "251_600": "RGBW" + } + }, + "section_red": { + "title": "Red", + "description": "Control the lights red color. Brightness group address is required." + }, + "ga_red_switch": { + "label": "Red switch" + }, + "ga_red_brightness": { + "label": "Red brightness" + }, + "section_green": { + "title": "Green", + "description": "Control the lights green color. Brightness group address is required." + }, + "ga_green_switch": { + "label": "Green switch" + }, + "ga_green_brightness": { + "label": "Green brightness" + }, + "section_blue": { + "title": "Blue", + "description": "Control the lights blue color. Brightness group address is required." + }, + "ga_blue_switch": { + "label": "Blue switch" + }, + "ga_blue_brightness": { + "label": "Blue brightness" + }, + "section_white": { + "title": "White", + "description": "Control the lights white color. Brightness group address is required." + }, + "ga_white_switch": { + "label": "White switch" + }, + "ga_white_brightness": { + "label": "White brightness" + }, + "ga_hue": { + "label": "Hue", + "description": "Control the lights hue." + }, + "ga_saturation": { + "label": "Saturation", + "description": "Control the lights saturation." + } + } + } + }, + "switch": { + "description": "The KNX switch platform is used as an interface to switching actuators.", + "knx": { + "section_switch": { + "title": "Switching", + "description": "DPT 1 group addresses controlling the switch function." + }, + "ga_switch": { + "label": "Switch", + "description": "Group address to switch the device on/off." + }, + "invert": { + "label": "Invert", + "description": "Invert payloads before processing or sending." + }, + "respond_to_read": { + "label": "Respond to read", + "description": "Respond to GroupValueRead telegrams received to the configured send address." + } + } + } + } + } } } diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index b40dc2246b8..3efcde7edb6 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -14,7 +14,7 @@ from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig -from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -33,6 +33,7 @@ from .storage.entity_store_validation import ( EntityStoreValidationSuccess, validate_entity_data, ) +from .storage.serialize import get_serialized_schema from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict if TYPE_CHECKING: @@ -57,6 +58,7 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_entity_config) websocket_api.async_register_command(hass, ws_get_entity_entries) websocket_api.async_register_command(hass, ws_create_device) + websocket_api.async_register_command(hass, ws_get_schema) if DOMAIN not in hass.data.get("frontend_panels", {}): await hass.http.async_register_static_paths( @@ -363,6 +365,28 @@ def ws_validate_entity( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/get_schema", + vol.Required(CONF_PLATFORM): vol.Coerce(Platform), + } +) +@websocket_api.async_response +async def ws_get_schema( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Provide serialized schema for platform.""" + if schema := get_serialized_schema(msg[CONF_PLATFORM]): + connection.send_result(msg["id"], schema) + return + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, "Unknown platform" + ) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr new file mode 100644 index 00000000000..ddf1c0181b9 --- /dev/null +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -0,0 +1,756 @@ +# serializer version: 1 +# name: test_knx_get_schema[binary_sensor] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_binary_sensor', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_sensor', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': True, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': False, + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_advanced_options', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ignore_internal_state', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'context_timeout', + 'optional': True, + 'selector': dict({ + 'number': dict({ + 'max': 10.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'reset_after', + 'optional': True, + 'selector': dict({ + 'number': dict({ + 'max': 600.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'required': True, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[cover] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_binary_control', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_up_down', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert_updown', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': False, + 'name': 'section_stop_control', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_stop', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_step', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_position_control', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_position_set', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_position_state', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': False, + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert_position', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_tilt_control', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_angle', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'invert_angle', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': False, + 'name': 'section_travel_time', + 'type': 'knx_section_flat', + }), + dict({ + 'default': 25, + 'name': 'travelling_time_up', + 'optional': True, + 'selector': dict({ + 'number': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 25, + 'name': 'travelling_time_down', + 'optional': True, + 'selector': dict({ + 'number': dict({ + 'max': 1000.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 's', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[light] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_switch', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_brightness', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_brightness', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_color_temp', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_color_temp', + 'optional': True, + 'options': dict({ + 'dptSelect': list([ + dict({ + 'dpt': dict({ + 'main': 7, + 'sub': 600, + }), + 'translation_key': '7_600', + 'value': '7.600', + }), + dict({ + 'dpt': dict({ + 'main': 9, + 'sub': None, + }), + 'translation_key': '9', + 'value': '9', + }), + dict({ + 'dpt': dict({ + 'main': 5, + 'sub': 1, + }), + 'translation_key': '5_001', + 'value': '5.001', + }), + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'default': 2700, + 'name': 'color_temp_min', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 10000.0, + 'min': 1.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 6000, + 'name': 'color_temp_max', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 10000.0, + 'min': 1.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'color', + 'optional': True, + 'schema': list([ + dict({ + 'schema': list([ + dict({ + 'name': 'ga_color', + 'optional': True, + 'options': dict({ + 'dptSelect': list([ + dict({ + 'dpt': dict({ + 'main': 232, + 'sub': 600, + }), + 'translation_key': '232_600', + 'value': '232.600', + }), + dict({ + 'dpt': dict({ + 'main': 251, + 'sub': 600, + }), + 'translation_key': '251_600', + 'value': '251.600', + }), + dict({ + 'dpt': dict({ + 'main': 242, + 'sub': 600, + }), + 'translation_key': '242_600', + 'value': '242.600', + }), + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'single_address', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'collapsible': False, + 'name': 'section_red', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_red_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_red_brightness', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_green', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_green_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_green_brightness', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_blue', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_blue_brightness', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_blue_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'section_white', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_white_brightness', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_white_switch', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'individual_addresses', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'name': 'ga_hue', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_saturation', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + ]), + 'translation_key': 'hsv_addresses', + 'type': 'knx_group_select_option', + }), + ]), + 'type': 'knx_group_select', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[switch] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'collapsible': False, + 'name': 'section_switch', + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_switch', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': False, + 'name': 'invert', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': False, + 'name': 'respond_to_read', + 'optional': True, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_knx_get_schema[tts] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Unknown platform', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- diff --git a/tests/components/knx/test_knx_selectors.py b/tests/components/knx/test_knx_selectors.py index 12acf691c08..cad61a0fb12 100644 --- a/tests/components/knx/test_knx_selectors.py +++ b/tests/components/knx/test_knx_selectors.py @@ -4,9 +4,20 @@ from typing import Any import pytest import voluptuous as vol +from voluptuous_serialize import convert from homeassistant.components.knx.const import ColorTempModes -from homeassistant.components.knx.storage.knx_selector import GASelector +from homeassistant.components.knx.storage.knx_selector import ( + AllSerializeFirst, + GASelector, + GroupSelect, + GroupSelectOption, + KNXSection, + KNXSectionFlat, + SyncStateSelector, +) +from homeassistant.components.knx.storage.serialize import knx_serializer +from homeassistant.helpers import selector INVALID = "invalid" @@ -14,48 +25,6 @@ INVALID = "invalid" @pytest.mark.parametrize( ("selector_config", "data", "expected"), [ - # empty data is invalid - ( - {}, - {}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"write": False}, - {}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"passive": False}, - {}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"write": False, "state": False, "passive": False}, - {}, - {INVALID: "At least one group address must be set"}, - ), - # stale data is invalid - ( - {"write": False}, - {"write": "1/2/3"}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"write": False}, - {"passive": []}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"state": False}, - {"write": None}, - {INVALID: "At least one group address must be set"}, - ), - ( - {"passive": False}, - {"passive": ["1/2/3"]}, - {INVALID: "At least one group address must be set"}, - ), # valid data ( {}, @@ -93,16 +62,6 @@ INVALID = "invalid" {"write": "1/2/3", "state": None}, ), # required keys - ( - {"write_required": True}, - {}, - {INVALID: r"required key not provided*"}, - ), - ( - {"state_required": True}, - {}, - {INVALID: r"required key not provided*"}, - ), ( {"write_required": True}, {"write": "1/2/3"}, @@ -113,32 +72,12 @@ INVALID = "invalid" {"state": "1/2/3"}, {"write": None, "state": "1/2/3", "passive": []}, ), - ( - {"write_required": True}, - {"state": "1/2/3"}, - {INVALID: r"required key not provided*"}, - ), - ( - {"state_required": True}, - {"write": "1/2/3"}, - {INVALID: r"required key not provided*"}, - ), # dpt key - ( - {"dpt": ColorTempModes}, - {"write": "1/2/3"}, - {INVALID: r"required key not provided*"}, - ), ( {"dpt": ColorTempModes}, {"write": "1/2/3", "dpt": "7.600"}, {"write": "1/2/3", "state": None, "passive": [], "dpt": "7.600"}, ), - ( - {"dpt": ColorTempModes}, - {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, - {INVALID: r"value must be one of ['5.001', '7.600', '9']*"}, - ), ], ) def test_ga_selector( @@ -148,9 +87,240 @@ def test_ga_selector( ) -> None: """Test GASelector.""" selector = GASelector(**selector_config) - if INVALID in expected: - with pytest.raises(vol.Invalid, match=expected[INVALID]): - selector(data) - else: - result = selector(data) - assert result == expected + result = selector(data) + assert result == expected + + +@pytest.mark.parametrize( + ("selector_config", "data", "error_str"), + [ + # empty data is invalid + ( + {}, + {}, + "At least one group address must be set", + ), + ( + {"write": False}, + {}, + "At least one group address must be set", + ), + ( + {"passive": False}, + {}, + "At least one group address must be set", + ), + ( + {"write": False, "state": False, "passive": False}, + {}, + "At least one group address must be set", + ), + # stale data is invalid + ( + {"write": False}, + {"write": "1/2/3"}, + "At least one group address must be set", + ), + ( + {"write": False}, + {"passive": []}, + "At least one group address must be set", + ), + ( + {"state": False}, + {"write": None}, + "At least one group address must be set", + ), + ( + {"passive": False}, + {"passive": ["1/2/3"]}, + "At least one group address must be set", + ), + # required keys + ( + {"write_required": True}, + {}, + r"required key not provided*", + ), + ( + {"state_required": True}, + {}, + r"required key not provided*", + ), + ( + {"write_required": True}, + {"state": "1/2/3"}, + r"required key not provided*", + ), + ( + {"state_required": True}, + {"write": "1/2/3"}, + r"required key not provided*", + ), + # dpt key + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3"}, + r"required key not provided*", + ), + ( + {"dpt": ColorTempModes}, + {"write": "1/2/3", "state": None, "passive": [], "dpt": "invalid"}, + r"value must be one of ['5.001', '7.600', '9']*", + ), + ], +) +def test_ga_selector_invalid( + selector_config: dict[str, Any], + data: dict[str, Any], + error_str: str, +) -> None: + """Test GASelector.""" + selector = GASelector(**selector_config) + with pytest.raises(vol.Invalid, match=error_str): + selector(data) + + +def test_sync_state_selector() -> None: + """Test SyncStateSelector.""" + selector = SyncStateSelector() + assert selector("expire 50") == "expire 50" + + with pytest.raises(vol.Invalid): + selector("invalid") + + with pytest.raises(vol.Invalid, match="Sync state cannot be False"): + selector(False) + + false_allowed = SyncStateSelector(allow_false=True) + assert false_allowed(False) is False + + +@pytest.mark.parametrize( + ("selector", "serialized"), + [ + ( + GASelector(), + { + "type": "knx_group_address", + "options": { + "write": {"required": False}, + "state": {"required": False}, + "passive": True, + }, + }, + ), + ( + GASelector( + state=False, write_required=True, passive=False, valid_dpt="5.001" + ), + { + "type": "knx_group_address", + "options": { + "write": {"required": True}, + "state": False, + "passive": False, + "validDPTs": [{"main": 5, "sub": 1}], + }, + }, + ), + ( + GASelector(dpt=ColorTempModes), + { + "type": "knx_group_address", + "options": { + "write": {"required": False}, + "state": {"required": False}, + "passive": True, + "dptSelect": [ + { + "value": "7.600", + "translation_key": "7_600", + "dpt": {"main": 7, "sub": 600}, + }, + { + "value": "9", + "translation_key": "9", + "dpt": {"main": 9, "sub": None}, + }, + { + "value": "5.001", + "translation_key": "5_001", + "dpt": {"main": 5, "sub": 1}, + }, + ], + }, + }, + ), + ], +) +def test_ga_selector_serialization( + selector: GASelector, serialized: dict[str, Any] +) -> None: + """Test GASelector serialization.""" + assert selector.serialize() == serialized + + +@pytest.mark.parametrize( + ("schema", "serialized"), + [ + ( + AllSerializeFirst(vol.Schema({"key": int}), vol.Schema({"ignored": str})), + [{"name": "key", "type": "integer"}], + ), + ( + KNXSectionFlat(collapsible=True), + {"type": "knx_section_flat", "collapsible": True}, + ), + ( + KNXSection( + collapsible=True, + schema={"key": int}, + ), + { + "type": "knx_section", + "collapsible": True, + "schema": [{"name": "key", "type": "integer"}], + }, + ), + ( + GroupSelect( + GroupSelectOption(translation_key="option_1", schema={"key_1": str}), + GroupSelectOption(translation_key="option_2", schema={"key_2": int}), + ), + { + "type": "knx_group_select", + "collapsible": True, + "schema": [ + { + "type": "knx_group_select_option", + "translation_key": "option_1", + "schema": [{"name": "key_1", "type": "string"}], + }, + { + "type": "knx_group_select_option", + "translation_key": "option_2", + "schema": [{"name": "key_2", "type": "integer"}], + }, + ], + }, + ), + ( + SyncStateSelector(), + { + "type": "knx_sync_state", + "allow_false": False, + }, + ), + ( + selector.BooleanSelector(), + { + "type": "ha_selector", + "selector": {"boolean": {}}, + }, + ), + ], +) +def test_serialization(schema: Any, serialized: dict[str, Any]) -> None: + """Test serialization of the selector.""" + assert convert(schema, custom_serializer=knx_serializer) == serialized diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 5c0f002a541..3a55ed67e3d 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -4,8 +4,13 @@ from typing import Any from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.knx.const import KNX_ADDRESS, KNX_MODULE_KEY +from homeassistant.components.knx.const import ( + KNX_ADDRESS, + KNX_MODULE_KEY, + SUPPORTED_PLATFORMS_UI, +) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY from homeassistant.components.knx.schema import SwitchSchema from homeassistant.const import CONF_NAME @@ -390,6 +395,22 @@ async def test_knx_subscribe_telegrams_command_project( assert res["event"]["timestamp"] is not None +@pytest.mark.parametrize("platform", {*SUPPORTED_PLATFORMS_UI, "tts"}) +async def test_knx_get_schema( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + platform: str, +) -> None: + """Test knx/get_schema command returning proper schema data.""" + await knx.setup_integration() + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_schema", "platform": platform}) + res = await client.receive_json() + assert res == snapshot + + @pytest.mark.parametrize( "endpoint", [