Add support for service translations (#95984)

This commit is contained in:
Franck Nijhof 2023-07-11 15:52:12 +02:00 committed by GitHub
parent f12f8bca03
commit f054de0ad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 483 additions and 119 deletions

View file

@ -1,17 +1,11 @@
# Describes the format for available light services # Describes the format for available light services
turn_on: turn_on:
name: Turn on
description: >
Turn on one or more lights and adjust properties of the light, even when
they are turned on already.
target: target:
entity: entity:
domain: light domain: light
fields: fields:
transition: transition:
name: Transition
description: Duration it takes to get to next state.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.TRANSITION - light.LightEntityFeature.TRANSITION
@ -21,8 +15,6 @@ turn_on:
max: 300 max: 300
unit_of_measurement: seconds unit_of_measurement: seconds
rgb_color: rgb_color:
name: Color
description: The color for the light (based on RGB - red, green, blue).
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -34,8 +26,6 @@ turn_on:
selector: selector:
color_rgb: color_rgb:
rgbw_color: rgbw_color:
name: RGBW-color
description: A list containing four integers between 0 and 255 representing the RGBW (red, green, blue, white) color for the light.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -49,8 +39,6 @@ turn_on:
selector: selector:
object: object:
rgbww_color: rgbww_color:
name: RGBWW-color
description: A list containing five integers between 0 and 255 representing the RGBWW (red, green, blue, cold white, warm white) color for the light.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -64,8 +52,6 @@ turn_on:
selector: selector:
object: object:
color_name: color_name:
name: Color name
description: A human readable color name.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -77,6 +63,7 @@ turn_on:
advanced: true advanced: true
selector: selector:
select: select:
translation_key: color_name
options: options:
- "homeassistant" - "homeassistant"
- "aliceblue" - "aliceblue"
@ -228,8 +215,6 @@ turn_on:
- "yellow" - "yellow"
- "yellowgreen" - "yellowgreen"
hs_color: hs_color:
name: Hue/Sat color
description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -243,8 +228,6 @@ turn_on:
selector: selector:
object: object:
xy_color: xy_color:
name: XY-color
description: Color for the light in XY-format.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -258,8 +241,6 @@ turn_on:
selector: selector:
object: object:
color_temp: color_temp:
name: Color temperature
description: Color temperature for the light in mireds.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -274,8 +255,6 @@ turn_on:
min_mireds: 153 min_mireds: 153
max_mireds: 500 max_mireds: 500
kelvin: kelvin:
name: Color temperature (Kelvin)
description: Color temperature for the light in Kelvin.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -293,10 +272,6 @@ turn_on:
step: 100 step: 100
unit_of_measurement: K unit_of_measurement: K
brightness: brightness:
name: Brightness value
description: Number indicating brightness, where 0 turns the light
off, 1 is the minimum brightness and 255 is the maximum brightness
supported by the light.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -313,10 +288,6 @@ turn_on:
min: 0 min: 0
max: 255 max: 255
brightness_pct: brightness_pct:
name: Brightness
description: Number indicating percentage of full brightness, where 0
turns the light off, 1 is the minimum brightness and 100 is the maximum
brightness supported by the light.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -333,8 +304,6 @@ turn_on:
max: 100 max: 100
unit_of_measurement: "%" unit_of_measurement: "%"
brightness_step: brightness_step:
name: Brightness step value
description: Change brightness by an amount.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -351,8 +320,6 @@ turn_on:
min: -225 min: -225
max: 255 max: 255
brightness_step_pct: brightness_step_pct:
name: Brightness step
description: Change brightness by a percentage.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -369,8 +336,6 @@ turn_on:
max: 100 max: 100
unit_of_measurement: "%" unit_of_measurement: "%"
white: white:
name: White
description: Set the light to white mode.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -381,15 +346,11 @@ turn_on:
value: true value: true
label: Enabled label: Enabled
profile: profile:
name: Profile
description: Name of a light profile to use.
advanced: true advanced: true
example: relax example: relax
selector: selector:
text: text:
flash: flash:
name: Flash
description: If the light should flash.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.FLASH - light.LightEntityFeature.FLASH
@ -402,8 +363,6 @@ turn_on:
- label: "Short" - label: "Short"
value: "short" value: "short"
effect: effect:
name: Effect
description: Light effect.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.EFFECT - light.LightEntityFeature.EFFECT
@ -411,15 +370,11 @@ turn_on:
text: text:
turn_off: turn_off:
name: Turn off
description: Turns off one or more lights.
target: target:
entity: entity:
domain: light domain: light
fields: fields:
transition: transition:
name: Transition
description: Duration it takes to get to next state.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.TRANSITION - light.LightEntityFeature.TRANSITION
@ -429,8 +384,6 @@ turn_off:
max: 300 max: 300
unit_of_measurement: seconds unit_of_measurement: seconds
flash: flash:
name: Flash
description: If the light should flash.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.FLASH - light.LightEntityFeature.FLASH
@ -444,17 +397,11 @@ turn_off:
value: "short" value: "short"
toggle: toggle:
name: Toggle
description: >
Toggles one or more lights, from on to off, or, off to on, based on their
current state.
target: target:
entity: entity:
domain: light domain: light
fields: fields:
transition: transition:
name: Transition
description: Duration it takes to get to next state.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.TRANSITION - light.LightEntityFeature.TRANSITION
@ -464,8 +411,6 @@ toggle:
max: 300 max: 300
unit_of_measurement: seconds unit_of_measurement: seconds
rgb_color: rgb_color:
name: RGB-color
description: Color for the light in RGB-format.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -479,8 +424,6 @@ toggle:
selector: selector:
object: object:
color_name: color_name:
name: Color name
description: A human readable color name.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -492,6 +435,7 @@ toggle:
advanced: true advanced: true
selector: selector:
select: select:
translation_key: color_name
options: options:
- "homeassistant" - "homeassistant"
- "aliceblue" - "aliceblue"
@ -643,8 +587,6 @@ toggle:
- "yellow" - "yellow"
- "yellowgreen" - "yellowgreen"
hs_color: hs_color:
name: Hue/Sat color
description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -658,8 +600,6 @@ toggle:
selector: selector:
object: object:
xy_color: xy_color:
name: XY-color
description: Color for the light in XY-format.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -673,8 +613,6 @@ toggle:
selector: selector:
object: object:
color_temp: color_temp:
name: Color temperature (mireds)
description: Color temperature for the light in mireds.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -688,8 +626,6 @@ toggle:
selector: selector:
color_temp: color_temp:
kelvin: kelvin:
name: Color temperature (Kelvin)
description: Color temperature for the light in Kelvin.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -707,10 +643,6 @@ toggle:
step: 100 step: 100
unit_of_measurement: K unit_of_measurement: K
brightness: brightness:
name: Brightness value
description: Number indicating brightness, where 0 turns the light
off, 1 is the minimum brightness and 255 is the maximum brightness
supported by the light.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -727,10 +659,6 @@ toggle:
min: 0 min: 0
max: 255 max: 255
brightness_pct: brightness_pct:
name: Brightness
description: Number indicating percentage of full brightness, where 0
turns the light off, 1 is the minimum brightness and 100 is the maximum
brightness supported by the light.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -747,8 +675,6 @@ toggle:
max: 100 max: 100
unit_of_measurement: "%" unit_of_measurement: "%"
white: white:
name: White
description: Set the light to white mode.
filter: filter:
attribute: attribute:
supported_color_modes: supported_color_modes:
@ -759,15 +685,11 @@ toggle:
value: true value: true
label: Enabled label: Enabled
profile: profile:
name: Profile
description: Name of a light profile to use.
advanced: true advanced: true
example: relax example: relax
selector: selector:
text: text:
flash: flash:
name: Flash
description: If the light should flash.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.FLASH - light.LightEntityFeature.FLASH
@ -780,8 +702,6 @@ toggle:
- label: "Short" - label: "Short"
value: "short" value: "short"
effect: effect:
name: Effect
description: Light effect.
filter: filter:
supported_features: supported_features:
- light.LightEntityFeature.EFFECT - light.LightEntityFeature.EFFECT

View file

@ -86,5 +86,307 @@
} }
} }
} }
},
"selector": {
"color_name": {
"options": {
"homeassistant": "Home Assistant",
"aliceblue": "Alice blue",
"antiquewhite": "Antique white",
"aqua": "Aqua",
"aquamarine": "Aquamarine",
"azure": "Azure",
"beige": "Beige",
"bisque": "Bisque",
"blanchedalmond": "Blanched almond",
"blue": "Blue",
"blueviolet": "Blue violet",
"brown": "Brown",
"burlywood": "Burlywood",
"cadetblue": "Cadet blue",
"chartreuse": "Chartreuse",
"chocolate": "Chocolate",
"coral": "Coral",
"cornflowerblue": "Cornflower blue",
"cornsilk": "Cornsilk",
"crimson": "Crimson",
"cyan": "Cyan",
"darkblue": "Dark blue",
"darkcyan": "Dark cyan",
"darkgoldenrod": "Dark goldenrod",
"darkgray": "Dark gray",
"darkgreen": "Dark green",
"darkgrey": "Dark grey",
"darkkhaki": "Dark khaki",
"darkmagenta": "Dark magenta",
"darkolivegreen": "Dark olive green",
"darkorange": "Dark orange",
"darkorchid": "Dark orchid",
"darkred": "Dark red",
"darksalmon": "Dark salmon",
"darkseagreen": "Dark sea green",
"darkslateblue": "Dark slate blue",
"darkslategray": "Dark slate gray",
"darkslategrey": "Dark slate grey",
"darkturquoise": "Dark turquoise",
"darkviolet": "Dark violet",
"deeppink": "Deep pink",
"deepskyblue": "Deep sky blue",
"dimgray": "Dim gray",
"dimgrey": "Dim grey",
"dodgerblue": "Dodger blue",
"firebrick": "Fire brick",
"floralwhite": "Floral white",
"forestgreen": "Forest green",
"fuchsia": "Fuchsia",
"gainsboro": "Gainsboro",
"ghostwhite": "Ghost white",
"gold": "Gold",
"goldenrod": "Goldenrod",
"gray": "Gray",
"green": "Green",
"greenyellow": "Green yellow",
"grey": "Grey",
"honeydew": "Honeydew",
"hotpink": "Hot pink",
"indianred": "Indian red",
"indigo": "Indigo",
"ivory": "Ivory",
"khaki": "Khaki",
"lavender": "Lavender",
"lavenderblush": "Lavender blush",
"lawngreen": "Lawn green",
"lemonchiffon": "Lemon chiffon",
"lightblue": "Light blue",
"lightcoral": "Light coral",
"lightcyan": "Light cyan",
"lightgoldenrodyellow": "Light goldenrod yellow",
"lightgray": "Light gray",
"lightgreen": "Light green",
"lightgrey": "Light grey",
"lightpink": "Light pink",
"lightsalmon": "Light salmon",
"lightseagreen": "Light sea green",
"lightskyblue": "Light sky blue",
"lightslategray": "Light slate gray",
"lightslategrey": "Light slate grey",
"lightsteelblue": "Light steel blue",
"lightyellow": "Light yellow",
"lime": "Lime",
"limegreen": "Lime green",
"linen": "Linen",
"magenta": "Magenta",
"maroon": "Maroon",
"mediumaquamarine": "Medium aquamarine",
"mediumblue": "Medium blue",
"mediumorchid": "Medium orchid",
"mediumpurple": "Medium purple",
"mediumseagreen": "Medium sea green",
"mediumslateblue": "Medium slate blue",
"mediumspringgreen": "Medium spring green",
"mediumturquoise": "Medium turquoise",
"mediumvioletred": "Medium violet red",
"midnightblue": "Midnight blue",
"mintcream": "Mint cream",
"mistyrose": "Misty rose",
"moccasin": "Moccasin",
"navajowhite": "Navajo white",
"navy": "Navy",
"navyblue": "Navy blue",
"oldlace": "Old lace",
"olive": "Olive",
"olivedrab": "Olive drab",
"orange": "Orange",
"orangered": "Orange red",
"orchid": "Orchid",
"palegoldenrod": "Pale goldenrod",
"palegreen": "Pale green",
"paleturquoise": "Pale turquoise",
"palevioletred": "Pale violet red",
"papayawhip": "Papaya whip",
"peachpuff": "Peach puff",
"peru": "Peru",
"pink": "Pink",
"plum": "Plum",
"powderblue": "Powder blue",
"purple": "Purple",
"red": "Red",
"rosybrown": "Rosy brown",
"royalblue": "Royal blue",
"saddlebrown": "Saddle brown",
"salmon": "Salmon",
"sandybrown": "Sandy brown",
"seagreen": "Sea green",
"seashell": "Seashell",
"sienna": "Sienna",
"silver": "Silver",
"skyblue": "Sky blue",
"slateblue": "Slate blue",
"slategray": "Slate gray",
"slategrey": "Slate grey",
"snow": "Snow",
"springgreen": "Spring green",
"steelblue": "Steel blue",
"tan": "Tan",
"teal": "Teal",
"thistle": "Thistle",
"tomato": "Tomato",
"turquoise": "Turquoise",
"violet": "Violet",
"wheat": "Wheat",
"white": "White",
"whitesmoke": "White smoke",
"yellow": "Yellow",
"yellowgreen": "Yellow green"
}
}
},
"services": {
"turn_on": {
"name": "Turn on",
"description": "Turn on one or more lights and adjust properties of the light, even when they are turned on already.",
"fields": {
"transition": {
"name": "Transition",
"description": "Duration it takes to get to next state."
},
"rgb_color": {
"name": "Color",
"description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue."
},
"rgbw_color": {
"name": "RGBW-color",
"description": "The color in RGBW format. A list of four integers between 0 and 255 representing the values of red, green, blue, and white."
},
"rgbww_color": {
"name": "RGBWW-color",
"description": "The color in RGBWW format. A list of five integers between 0 and 255 representing the values of red, green, blue, cold white, and warm white."
},
"color_name": {
"name": "Color name",
"description": "A human readable color name."
},
"hs_color": {
"name": "Hue/Sat color",
"description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100."
},
"xy_color": {
"name": "XY-color",
"description": "Color in XY-format. A list of two decimal numbers between 0 and 1."
},
"color_temp": {
"name": "Color temperature",
"description": "Color temperature in mireds."
},
"kelvin": {
"name": "Color temperature",
"description": "Color temperature in Kelvin."
},
"brightness": {
"name": "Brightness value",
"description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness."
},
"brightness_pct": {
"name": "Brightness",
"description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness."
},
"brightness_step": {
"name": "Brightness step value",
"description": "Change brightness by an amount."
},
"brightness_step_pct": {
"name": "Brightness step",
"description": "Change brightness by a percentage."
},
"white": {
"name": "White",
"description": "Set the light to white mode."
},
"profile": {
"name": "Profile",
"description": "Name of a light profile to use."
},
"flash": {
"name": "Flash",
"description": "If the light should flash."
},
"effect": {
"name": "Effect",
"description": "Light effect."
}
}
},
"turn_off": {
"name": "Turn off",
"description": "Turn off one or more lights.",
"fields": {
"transition": {
"name": "[%key:component::light::services::turn_on::fields::transition::name%]",
"description": "[%key:component::light::services::turn_on::fields::transition::description%]"
},
"flash": {
"name": "[%key:component::light::services::turn_on::fields::flash::name%]",
"description": "[%key:component::light::services::turn_on::fields::flash::description%]"
}
}
},
"toggle": {
"name": "Toggle",
"description": "Toggles one or more lights, from on to off, or, off to on, based on their current state.",
"fields": {
"transition": {
"name": "[%key:component::light::services::turn_on::fields::transition::name%]",
"description": "[%key:component::light::services::turn_on::fields::transition::description%]"
},
"rgb_color": {
"name": "[%key:component::light::services::turn_on::fields::rgb_color::name%]",
"description": "[%key:component::light::services::turn_on::fields::rgb_color::description%]"
},
"color_name": {
"name": "[%key:component::light::services::turn_on::fields::color_name::name%]",
"description": "[%key:component::light::services::turn_on::fields::color_name::description%]"
},
"hs_color": {
"name": "[%key:component::light::services::turn_on::fields::hs_color::name%]",
"description": "[%key:component::light::services::turn_on::fields::hs_color::description%]"
},
"xy_color": {
"name": "[%key:component::light::services::turn_on::fields::xy_color::name%]",
"description": "[%key:component::light::services::turn_on::fields::xy_color::description%]"
},
"color_temp": {
"name": "[%key:component::light::services::turn_on::fields::color_temp::name%]",
"description": "[%key:component::light::services::turn_on::fields::color_temp::description%]"
},
"kelvin": {
"name": "[%key:component::light::services::turn_on::fields::kelvin::name%]",
"description": "[%key:component::light::services::turn_on::fields::kelvin::description%]"
},
"brightness": {
"name": "[%key:component::light::services::turn_on::fields::brightness::name%]",
"description": "[%key:component::light::services::turn_on::fields::brightness::description%]"
},
"brightness_pct": {
"name": "[%key:component::light::services::turn_on::fields::brightness_pct::name%]",
"description": "[%key:component::light::services::turn_on::fields::brightness_pct::description%]"
},
"white": {
"name": "[%key:component::light::services::turn_on::fields::white::name%]",
"description": "[%key:component::light::services::turn_on::fields::white::description%]"
},
"profile": {
"name": "[%key:component::light::services::turn_on::fields::profile::name%]",
"description": "[%key:component::light::services::turn_on::fields::profile::description%]"
},
"flash": {
"name": "[%key:component::light::services::turn_on::fields::flash::name%]",
"description": "[%key:component::light::services::turn_on::fields::flash::description%]"
},
"effect": {
"name": "[%key:component::light::services::turn_on::fields::effect::name%]",
"description": "[%key:component::light::services::turn_on::fields::effect::description%]"
}
}
}
} }
} }

View file

@ -50,6 +50,7 @@ from . import (
device_registry, device_registry,
entity_registry, entity_registry,
template, template,
translation,
) )
from .selector import TargetSelector from .selector import TargetSelector
from .typing import ConfigType, TemplateVarsType from .typing import ConfigType, TemplateVarsType
@ -607,6 +608,11 @@ async def async_get_all_descriptions(
) )
loaded = dict(zip(missing, contents)) loaded = dict(zip(missing, contents))
# Load translations for all service domains
translations = await translation.async_get_translations(
hass, "en", "services", list(services)
)
# Build response # Build response
descriptions: dict[str, dict[str, Any]] = {} descriptions: dict[str, dict[str, Any]] = {}
for domain, services_map in services.items(): for domain, services_map in services.items():
@ -616,8 +622,11 @@ async def async_get_all_descriptions(
for service_name in services_map: for service_name in services_map:
cache_key = (domain, service_name) cache_key = (domain, service_name)
description = descriptions_cache.get(cache_key) description = descriptions_cache.get(cache_key)
if description is not None:
domain_descriptions[service_name] = description
continue
# Cache missing descriptions # Cache missing descriptions
if description is None:
domain_yaml = loaded.get(domain) or {} domain_yaml = loaded.get(domain) or {}
# The YAML may be empty for dynamically defined # The YAML may be empty for dynamically defined
# services (ie shell_command) that never call # services (ie shell_command) that never call
@ -630,12 +639,34 @@ async def async_get_all_descriptions(
# Don't warn for missing services, because it triggers false # Don't warn for missing services, because it triggers false
# positives for things like scripts, that register as a service # positives for things like scripts, that register as a service
#
# When name & description are in the translations use those;
# otherwise fallback to backwards compatible behavior from
# the time when we didn't have translations for descriptions yet.
# This mimics the behavior of the frontend.
description = { description = {
"name": yaml_description.get("name", ""), "name": translations.get(
"description": yaml_description.get("description", ""), f"component.{domain}.services.{service_name}.name",
"fields": yaml_description.get("fields", {}), yaml_description.get("name", ""),
),
"description": translations.get(
f"component.{domain}.services.{service_name}.description",
yaml_description.get("description", ""),
),
"fields": dict(yaml_description.get("fields", {})),
} }
# Translate fields names & descriptions as well
for field_name, field_schema in description["fields"].items():
if name := translations.get(
f"component.{domain}.services.{service_name}.fields.{field_name}.name"
):
field_schema["name"] = name
if desc := translations.get(
f"component.{domain}.services.{service_name}.fields.{field_name}.description"
):
field_schema["description"] = desc
if "target" in yaml_description: if "target" in yaml_description:
description["target"] = yaml_description["target"] description["target"] = yaml_description["target"]

View file

@ -302,7 +302,7 @@ async def async_get_translations(
components = set(integrations) components = set(integrations)
elif config_flow: elif config_flow:
components = (await async_get_config_flows(hass)) - hass.config.components components = (await async_get_config_flows(hass)) - hass.config.components
elif category in ("state", "entity_component"): elif category in ("state", "entity_component", "services"):
components = set(hass.config.components) components = set(hass.config.components)
else: else:
# Only 'state' supports merging, so remove platforms from selection # Only 'state' supports merging, so remove platforms from selection

View file

@ -1,6 +1,8 @@
"""Validate dependencies.""" """Validate dependencies."""
from __future__ import annotations from __future__ import annotations
import contextlib
import json
import pathlib import pathlib
import re import re
from typing import Any from typing import Any
@ -25,7 +27,7 @@ def exists(value: Any) -> Any:
FIELD_SCHEMA = vol.Schema( FIELD_SCHEMA = vol.Schema(
{ {
vol.Required("description"): str, vol.Optional("description"): str,
vol.Optional("name"): str, vol.Optional("name"): str,
vol.Optional("example"): exists, vol.Optional("example"): exists,
vol.Optional("default"): exists, vol.Optional("default"): exists,
@ -46,7 +48,7 @@ FIELD_SCHEMA = vol.Schema(
SERVICE_SCHEMA = vol.Schema( SERVICE_SCHEMA = vol.Schema(
{ {
vol.Required("description"): str, vol.Optional("description"): str,
vol.Optional("name"): str, vol.Optional("name"): str,
vol.Optional("target"): vol.Any(selector.TargetSelector.CONFIG_SCHEMA, None), vol.Optional("target"): vol.Any(selector.TargetSelector.CONFIG_SCHEMA, None),
vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}),
@ -70,7 +72,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool
return False return False
def validate_services(integration: Integration) -> None: def validate_services(config: Config, integration: Integration) -> None:
"""Validate services.""" """Validate services."""
try: try:
data = load_yaml(str(integration.path / "services.yaml")) data = load_yaml(str(integration.path / "services.yaml"))
@ -92,15 +94,75 @@ def validate_services(integration: Integration) -> None:
return return
try: try:
SERVICES_SCHEMA(data) services = SERVICES_SCHEMA(data)
except vol.Invalid as err: except vol.Invalid as err:
integration.add_error( integration.add_error(
"services", f"Invalid services.yaml: {humanize_error(data, err)}" "services", f"Invalid services.yaml: {humanize_error(data, err)}"
) )
# Try loading translation strings
if integration.core:
strings_file = integration.path / "strings.json"
else:
# For custom integrations, use the en.json file
strings_file = integration.path / "translations/en.json"
strings = {}
if strings_file.is_file():
with contextlib.suppress(ValueError):
strings = json.loads(strings_file.read_text())
# For each service in the integration, check if the description if set,
# if not, check if it's in the strings file. If not, add an error.
for service_name, service_schema in services.items():
if "name" not in service_schema:
try:
strings["services"][service_name]["name"]
except KeyError:
integration.add_error(
"services",
f"Service {service_name} has no name and is not in the translations file",
)
if "description" not in service_schema:
try:
strings["services"][service_name]["description"]
except KeyError:
integration.add_error(
"services",
f"Service {service_name} has no description and is not in the translations file",
)
# 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 "description" not in field_schema:
try:
strings["services"][service_name]["fields"][field_name][
"description"
]
except KeyError:
integration.add_error(
"services",
f"Service {service_name} has a field {field_name} with no description and is not in the translations file",
)
if "selector" in field_schema:
with contextlib.suppress(KeyError):
translation_key = field_schema["selector"]["select"][
"translation_key"
]
try:
strings["selector"][translation_key]
except KeyError:
integration.add_error(
"services",
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",
)
def validate(integrations: dict[str, Integration], config: Config) -> None: def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Handle dependencies for integrations.""" """Handle dependencies for integrations."""
# check services.yaml is cool # check services.yaml is cool
for integration in integrations.values(): for integration in integrations.values():
validate_services(integration) validate_services(config, integration)

View file

@ -326,6 +326,20 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
), ),
slug_validator=cv.slug, slug_validator=cv.slug,
), ),
vol.Optional("services"): cv.schema_with_slug_keys(
{
vol.Required("name"): translation_value_validator,
vol.Required("description"): translation_value_validator,
vol.Optional("fields"): cv.schema_with_slug_keys(
{
vol.Required("name"): str,
vol.Required("description"): translation_value_validator,
},
slug_validator=translation_key_validator,
),
},
slug_validator=translation_key_validator,
),
} }
) )

View file

@ -1,6 +1,8 @@
"""Test service helpers.""" """Test service helpers."""
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Iterable
from copy import deepcopy from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
@ -556,13 +558,47 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
logger = hass.components.logger logger = hass.components.logger
logger_config = {logger.DOMAIN: {}} logger_config = {logger.DOMAIN: {}}
async def async_get_translations(
hass: HomeAssistant,
language: str,
category: str,
integrations: Iterable[str] | None = None,
config_flow: bool | None = None,
) -> dict[str, Any]:
"""Return all backend translations."""
translation_key_prefix = f"component.{logger.DOMAIN}.services.set_default_level"
return {
f"{translation_key_prefix}.name": "Translated name",
f"{translation_key_prefix}.description": "Translated description",
f"{translation_key_prefix}.fields.level.name": "Field name",
f"{translation_key_prefix}.fields.level.description": "Field description",
}
with patch(
"homeassistant.helpers.service.translation.async_get_translations",
side_effect=async_get_translations,
):
await async_setup_component(hass, logger.DOMAIN, logger_config) await async_setup_component(hass, logger.DOMAIN, logger_config)
descriptions = await service.async_get_all_descriptions(hass) descriptions = await service.async_get_all_descriptions(hass)
assert len(descriptions) == 2 assert len(descriptions) == 2
assert "description" in descriptions[logger.DOMAIN]["set_level"] assert descriptions[logger.DOMAIN]["set_default_level"]["name"] == "Translated name"
assert "fields" in descriptions[logger.DOMAIN]["set_level"] assert (
descriptions[logger.DOMAIN]["set_default_level"]["description"]
== "Translated description"
)
assert (
descriptions[logger.DOMAIN]["set_default_level"]["fields"]["level"]["name"]
== "Field name"
)
assert (
descriptions[logger.DOMAIN]["set_default_level"]["fields"]["level"][
"description"
]
== "Field description"
)
hass.services.async_register(logger.DOMAIN, "new_service", lambda x: None, None) hass.services.async_register(logger.DOMAIN, "new_service", lambda x: None, None)
service.async_set_service_schema( service.async_set_service_schema(
@ -602,7 +638,6 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
"another_service_with_response", "another_service_with_response",
{"description": "response service"}, {"description": "response service"},
) )
descriptions = await service.async_get_all_descriptions(hass) descriptions = await service.async_get_all_descriptions(hass)
assert "another_new_service" in descriptions[logger.DOMAIN] assert "another_new_service" in descriptions[logger.DOMAIN]
assert "service_with_optional_response" in descriptions[logger.DOMAIN] assert "service_with_optional_response" in descriptions[logger.DOMAIN]