From 75c7ae7c699ab1c53d6da4d496005d0580ffedcf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Jun 2024 20:00:48 +0200 Subject: [PATCH] Support in service descriptions for input sections (#116100) --- homeassistant/components/light/services.yaml | 180 +++++++++---------- homeassistant/components/light/strings.json | 18 +- homeassistant/helpers/service.py | 11 +- script/hassfest/services.py | 55 +++++- script/hassfest/translations.py | 7 + tests/helpers/test_service.py | 22 +++ 6 files changed, 198 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 6183d2a49df9..2a1fbd11afd6 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -199,45 +199,6 @@ turn_on: example: "[255, 100, 100]" selector: color_rgb: - rgbw_color: &rgbw_color - filter: *color_support - advanced: true - example: "[255, 100, 100, 50]" - selector: - object: - rgbww_color: &rgbww_color - filter: *color_support - advanced: true - example: "[255, 100, 100, 50, 70]" - selector: - object: - color_name: &color_name - filter: *color_support - advanced: true - selector: - select: - translation_key: color_name - options: *named_colors - hs_color: &hs_color - filter: *color_support - advanced: true - example: "[300, 70]" - selector: - object: - xy_color: &xy_color - filter: *color_support - advanced: true - example: "[0.52, 0.43]" - selector: - object: - color_temp: &color_temp - filter: *color_temp_support - advanced: true - selector: - color_temp: - unit: "mired" - min: 153 - max: 500 kelvin: &kelvin filter: *color_temp_support selector: @@ -245,13 +206,6 @@ turn_on: unit: "kelvin" min: 2000 max: 6500 - brightness: &brightness - filter: *brightness_support - advanced: true - selector: - number: - min: 0 - max: 255 brightness_pct: &brightness_pct filter: *brightness_support selector: @@ -259,13 +213,6 @@ turn_on: min: 0 max: 100 unit_of_measurement: "%" - brightness_step: - filter: *brightness_support - advanced: true - selector: - number: - min: -225 - max: 255 brightness_step_pct: filter: *brightness_support selector: @@ -273,39 +220,84 @@ turn_on: min: -100 max: 100 unit_of_measurement: "%" - white: &white - filter: - attribute: - supported_color_modes: - - light.ColorMode.WHITE - advanced: true - selector: - constant: - value: true - label: Enabled - profile: &profile - advanced: true - example: relax - selector: - text: - flash: &flash - filter: - supported_features: - - light.LightEntityFeature.FLASH - advanced: true - selector: - select: - options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" effect: &effect filter: supported_features: - light.LightEntityFeature.EFFECT selector: text: + advanced_fields: + collapsed: true + fields: + rgbw_color: &rgbw_color + filter: *color_support + example: "[255, 100, 100, 50]" + selector: + object: + rgbww_color: &rgbww_color + filter: *color_support + example: "[255, 100, 100, 50, 70]" + selector: + object: + color_name: &color_name + filter: *color_support + selector: + select: + translation_key: color_name + options: *named_colors + hs_color: &hs_color + filter: *color_support + example: "[300, 70]" + selector: + object: + xy_color: &xy_color + filter: *color_support + example: "[0.52, 0.43]" + selector: + object: + color_temp: &color_temp + filter: *color_temp_support + selector: + color_temp: + unit: "mired" + min: 153 + max: 500 + brightness: &brightness + filter: *brightness_support + selector: + number: + min: 0 + max: 255 + brightness_step: + filter: *brightness_support + selector: + number: + min: -225 + max: 255 + white: &white + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE + selector: + constant: + value: true + label: Enabled + profile: &profile + example: relax + selector: + text: + flash: &flash + filter: + supported_features: + - light.LightEntityFeature.FLASH + selector: + select: + options: + - label: "Long" + value: "long" + - label: "Short" + value: "short" turn_off: target: @@ -313,7 +305,10 @@ turn_off: domain: light fields: transition: *transition - flash: *flash + advanced_fields: + collapsed: true + fields: + flash: *flash toggle: target: @@ -322,16 +317,19 @@ toggle: fields: transition: *transition rgb_color: *rgb_color - rgbw_color: *rgbw_color - rgbww_color: *rgbww_color - color_name: *color_name - hs_color: *hs_color - xy_color: *xy_color - color_temp: *color_temp kelvin: *kelvin - brightness: *brightness brightness_pct: *brightness_pct - white: *white - profile: *profile - flash: *flash effect: *effect + advanced_fields: + collapsed: true + fields: + rgbw_color: *rgbw_color + rgbww_color: *rgbww_color + color_name: *color_name + hs_color: *hs_color + xy_color: *xy_color + color_temp: *color_temp + brightness: *brightness + white: *white + profile: *profile + flash: *flash diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 761564049915..b874e48406ea 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -34,7 +34,8 @@ "field_white_description": "Set the light to white mode.", "field_white_name": "White", "field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.", - "field_xy_color_name": "XY-color" + "field_xy_color_name": "XY-color", + "section_advanced_fields_name": "Advanced options" }, "device_automation": { "action_type": { @@ -354,6 +355,11 @@ "name": "[%key:component::light::common::field_effect_name%]", "description": "[%key:component::light::common::field_effect_description%]" } + }, + "sections": { + "advanced_fields": { + "name": "[%key:component::light::common::section_advanced_fields_name%]" + } } }, "turn_off": { @@ -368,6 +374,11 @@ "name": "[%key:component::light::common::field_flash_name%]", "description": "[%key:component::light::common::field_flash_description%]" } + }, + "sections": { + "advanced_fields": { + "name": "[%key:component::light::common::section_advanced_fields_name%]" + } } }, "toggle": { @@ -434,6 +445,11 @@ "name": "[%key:component::light::common::field_effect_name%]", "description": "[%key:component::light::common::field_effect_description%]" } + }, + "sections": { + "advanced_fields": { + "name": "[%key:component::light::common::section_advanced_fields_name%]" + } } } } diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 22f5e7f87101..35c682437cb1 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -179,10 +179,19 @@ _FIELD_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_SECTION_SCHEMA = vol.Schema( + { + vol.Required("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + _SERVICE_SCHEMA = vol.Schema( { vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), - vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + vol.Optional("fields"): vol.Schema( + {str: vol.Any(_SECTION_SCHEMA, _FIELD_SCHEMA)} + ), }, extra=vol.ALLOW_EXTRA, ) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index ea4503d54102..92fca14d373c 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -26,6 +26,23 @@ def exists(value: Any) -> Any: return value +def unique_field_validator(fields: Any) -> Any: + """Validate the inputs don't have duplicate keys under different sections.""" + all_fields = set() + for key, value in fields.items(): + if value and "fields" in value: + for key in value["fields"]: + if key in all_fields: + raise vol.Invalid(f"Duplicate use of field {key} in service.") + all_fields.add(key) + else: + if key in all_fields: + raise vol.Invalid(f"Duplicate use of field {key} in service.") + all_fields.add(key) + + return fields + + CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( { vol.Optional("example"): exists, @@ -44,6 +61,13 @@ CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( } ) +CORE_INTEGRATION_SECTION_SCHEMA = vol.Schema( + { + vol.Optional("collapsed"): bool, + vol.Required("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + } +) + CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( { vol.Optional("description"): str, @@ -57,7 +81,17 @@ CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( vol.Optional("target"): vol.Any( selector.TargetSelector.CONFIG_SCHEMA, None ), - vol.Optional("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + CORE_INTEGRATION_FIELD_SCHEMA, + CORE_INTEGRATION_SECTION_SCHEMA, + ) + } + ), + unique_field_validator, + ), } ), None, @@ -107,7 +141,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool return False -def validate_services(config: Config, integration: Integration) -> None: +def validate_services(config: Config, integration: Integration) -> None: # noqa: C901 """Validate services.""" try: data = load_yaml_dict(str(integration.path / "services.yaml")) @@ -200,6 +234,9 @@ def validate_services(config: Config, integration: Integration) -> None: # The same check is done for the description in each of the fields of the # service schema. for field_name, field_schema in service_schema.get("fields", {}).items(): + if "fields" in field_schema: + # This is a section + continue if "name" not in field_schema: try: strings["services"][service_name]["fields"][field_name]["name"] @@ -233,6 +270,20 @@ def validate_services(config: Config, integration: Integration) -> None: f"Service {service_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", ) + # The same check is done for the description in each of the sections of the + # service schema. + for section_name, section_schema in service_schema.get("fields", {}).items(): + if "fields" not in section_schema: + # This is not a section + continue + try: + strings["services"][service_name]["sections"][section_name]["name"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a section {section_name} with no name {error_msg_suffix}", + ) + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle dependencies for integrations.""" diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 965d1dc62b82..c39c070eba22 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -383,6 +383,13 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=translation_key_validator, ), + vol.Optional("sections"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Optional("description"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), }, slug_validator=translation_key_validator, ), diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 3e7d8e6ef032..9c5cda67725b 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -990,6 +990,17 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + advanced_stuff: + fields: + temperature: + filter: + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + selector: + number: """ domain = "test_domain" @@ -1024,6 +1035,17 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: test_service_schema = { "description": "", "fields": { + "advanced_stuff": { + "fields": { + "temperature": { + "filter": { + "attribute": {"supported_color_modes": ["color_temp"]}, + "supported_features": [1], + }, + "selector": {"number": None}, + }, + }, + }, "temperature": { "filter": { "attribute": {"supported_color_modes": ["color_temp"]},