Compare commits

...

10 Commits

Author SHA1 Message Date
jbouwh
d5baad9a87 Linter 2025-11-04 19:55:26 +00:00
jbouwh
e50dbbf873 Add missed entity platform service 2025-11-04 19:50:02 +00:00
jbouwh
8d284244bb Add tests 2025-11-04 19:25:44 +00:00
jbouwh
229587d5b6 Also add description_placeholders option to async_register_entity_service and to async_register_platform_entity_service 2025-11-04 19:25:44 +00:00
jbouwh
d78dffd4a5 Revert KNX changes 2025-11-04 19:25:44 +00:00
jbouwh
10b387e9a5 Update test 2025-11-04 19:23:00 +00:00
jbouwh
f2e7e65849 Ruff 2025-11-04 19:23:00 +00:00
Jan Bouwhuis
c54905a5ba Update homeassistant/helpers/service.py
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-11-04 19:23:00 +00:00
jbouwh
854780fa8a Add placeholders to service actions descriptions and add to kitchen sink example 2025-11-04 19:23:00 +00:00
jbouwh
0b1817ad73 Add description placeholders to service translation strings 2025-11-04 19:20:50 +00:00
9 changed files with 210 additions and 17 deletions

View File

@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -76,11 +76,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
@callback
def service_handler(call: ServiceCall | None = None) -> None:
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
"""Do nothing."""
return None
hass.services.async_register(
DOMAIN, "test_service_1", service_handler, SCHEMA_SERVICE_TEST_SERVICE_1
DOMAIN,
"test_service_1",
service_handler,
SCHEMA_SERVICE_TEST_SERVICE_1,
description_placeholders={
"meep_1": "foo",
"meep_2": "bar",
"meep_3": "beer",
"meep_4": "milk",
"meep_5": "https://example.com",
},
)
return True

View File

@@ -105,14 +105,16 @@
},
"services": {
"test_service_1": {
"description": "Fake action for testing",
"description": "Fake action for testing {meep_2}",
"fields": {
"field_1": {
"description": "Number of seconds",
"name": "Field 1"
"description": "Number of seconds {meep_4}",
"example": "Example: {meep_5}",
"name": "Field 1 {meep_3}"
},
"field_2": {
"description": "Mode",
"example": "Field 2 example",
"name": "Field 2"
},
"field_3": {
@@ -124,7 +126,7 @@
"name": "Field 4"
}
},
"name": "Test action 1",
"name": "Test action {meep_1}",
"sections": {
"advanced_fields": {
"description": "Some very advanced things",

View File

@@ -2426,7 +2426,14 @@ class SupportsResponse(enum.StrEnum):
class Service:
"""Representation of a callable service."""
__slots__ = ["domain", "job", "schema", "service", "supports_response"]
__slots__ = [
"description_placeholders",
"domain",
"job",
"schema",
"service",
"supports_response",
]
def __init__(
self,
@@ -2443,11 +2450,13 @@ class Service:
context: Context | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
job_type: HassJobType | None = None,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Initialize a service."""
self.job = HassJob(func, f"service {domain}.{service}", job_type=job_type)
self.schema = schema
self.supports_response = supports_response
self.description_placeholders = description_placeholders
class ServiceCall:
@@ -2590,6 +2599,8 @@ class ServiceRegistry:
schema: VolSchemaType | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
job_type: HassJobType | None = None,
*,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Register a service.
@@ -2599,7 +2610,13 @@ class ServiceRegistry:
"""
self._hass.verify_event_loop_thread("hass.services.async_register")
self._async_register(
domain, service, service_func, schema, supports_response, job_type
domain,
service,
service_func,
schema,
supports_response,
job_type,
description_placeholders,
)
@callback
@@ -2617,6 +2634,7 @@ class ServiceRegistry:
schema: VolSchemaType | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
job_type: HassJobType | None = None,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Register a service.
@@ -2633,6 +2651,7 @@ class ServiceRegistry:
service,
supports_response=supports_response,
job_type=job_type,
description_placeholders=description_placeholders,
)
if domain in self._services:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Mapping
from datetime import timedelta
import logging
from types import ModuleType
@@ -251,6 +251,8 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
func: str | Callable[..., Any],
required_features: list[int] | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
*,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Register an entity service."""
service.async_register_entity_service(
@@ -263,6 +265,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
required_features=required_features,
schema=schema,
supports_response=supports_response,
description_placeholders=description_placeholders,
)
async def async_setup_platform(

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine, Iterable
from collections.abc import Awaitable, Callable, Coroutine, Iterable, Mapping
from contextvars import ContextVar
from datetime import timedelta
from logging import Logger, getLogger
@@ -1081,6 +1081,7 @@ class EntityPlatform:
supports_response: SupportsResponse = SupportsResponse.NONE,
*,
entity_device_classes: Iterable[str | None] | None = None,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Register an entity service.
@@ -1100,6 +1101,7 @@ class EntityPlatform:
required_features=required_features,
schema=schema,
supports_response=supports_response,
description_placeholders=description_placeholders,
)
async def _async_update_entity_states(self) -> None:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine, Iterable
from collections.abc import Callable, Coroutine, Iterable, Mapping
import dataclasses
from enum import Enum
from functools import cache, partial
@@ -612,6 +612,8 @@ async def async_get_all_descriptions(
# Don't warn for missing services, because it triggers false
# positives for things like scripts, that register as a service
description = {"fields": yaml_description.get("fields", {})}
if description_placeholders := service.description_placeholders:
description["description_placeholders"] = description_placeholders
for item in ("description", "name", "target"):
if item in yaml_description:
@@ -955,6 +957,8 @@ def async_register_admin_service(
],
schema: VolSchemaType = vol.Schema({}, extra=vol.PREVENT_EXTRA),
supports_response: SupportsResponse = SupportsResponse.NONE,
*,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Register a service that requires admin access."""
hass.services.async_register(
@@ -967,6 +971,7 @@ def async_register_admin_service(
),
schema,
supports_response,
description_placeholders=description_placeholders,
)
@@ -1112,6 +1117,7 @@ def async_register_entity_service(
domain: str,
name: str,
*,
description_placeholders: Mapping[str, str] | None = None,
entity_device_classes: Iterable[str | None] | None = None,
entities: dict[str, Entity],
func: str | Callable[..., Any],
@@ -1145,6 +1151,7 @@ def async_register_entity_service(
schema,
supports_response,
job_type=job_type,
description_placeholders=description_placeholders,
)
@@ -1154,6 +1161,7 @@ def async_register_platform_entity_service(
service_domain: str,
service_name: str,
*,
description_placeholders: Mapping[str, str] | None = None,
entity_device_classes: Iterable[str | None] | None = None,
entity_domain: str,
func: str | Callable[..., Any],
@@ -1191,4 +1199,5 @@ def async_register_platform_entity_service(
schema,
supports_response,
job_type=HassJobType.Coroutinefunction,
description_placeholders=description_placeholders,
)

View File

@@ -26,6 +26,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.entity_component import EntityComponent, async_update_entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -511,7 +512,7 @@ async def test_register_entity_service(
schema: dict | None,
service_data: dict,
) -> None:
"""Test registering an enttiy service and calling it."""
"""Test registering an entity service and calling it."""
entity = MockEntity(entity_id=f"{DOMAIN}.entity")
calls = []
@@ -525,7 +526,16 @@ async def test_register_entity_service(
await component.async_setup({})
await component.async_add_entities([entity])
component.async_register_entity_service("hello", schema, "async_called_by_service")
component.async_register_entity_service(
"hello",
schema,
"async_called_by_service",
description_placeholders={"test_placeholder": "beer"},
)
descriptions = await async_get_all_descriptions(hass)
assert descriptions["test_domain"]["hello"]["description_placeholders"] == {
"test_placeholder": "beer"
}
with pytest.raises(vol.Invalid):
await hass.services.async_call(

View File

@@ -40,6 +40,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -1639,10 +1640,20 @@ async def test_platforms_sharing_services(hass: HomeAssistant) -> None:
def handle_service(entity, data):
entities.append(entity)
entity_platform1.async_register_entity_service("hello", {}, handle_service)
entity_platform2.async_register_entity_service(
"hello", {}, Mock(side_effect=AssertionError("Should not be called"))
entity_platform1.async_register_entity_service(
"hello", {}, handle_service, description_placeholders={"drink": "beer"}
)
entity_platform2.async_register_entity_service(
"hello",
{},
Mock(side_effect=AssertionError("Should not be called")),
description_placeholders={"drink": "milk"},
)
descriptions = await async_get_all_descriptions(hass)
assert descriptions["mock_platform"]["hello"]["description_placeholders"] == {
"drink": "beer"
}
await hass.services.async_call(
"mock_platform", "hello", {"entity_id": "all"}, blocking=True

View File

@@ -1393,6 +1393,94 @@ async def test_async_get_all_descriptions_new_service_added_while_loading(
assert descriptions[logger_domain]["new_service"]["description"] == "new service"
async def test_async_get_descriptions_with_placeholders(hass: HomeAssistant) -> None:
"""Test descriptions async_get_all_descriptions with placeholders.
Placeholders supplied with a service registration should be included.
"""
service_descriptions = """
happy_time:
fields:
topic:
selector:
text:
duration:
default: 5
selector:
number:
min: 1
max: 300
unit_of_measurement: "seconds"
"""
service_schema = vol.Schema(
{
"topic": cv.string,
"duration": cv.positive_int,
}
)
domain = "test_domain"
hass.services.async_register(
domain,
"happy_time",
lambda call: None,
schema=service_schema,
description_placeholders={"placeholder": "beer"},
)
mock_integration(hass, MockModule(domain), top_level_files={"services.yaml"})
assert await async_setup_component(hass, domain, {})
def load_yaml(fname, secrets=None):
with io.StringIO(service_descriptions) as file:
return parse_yaml(file)
with (
patch(
"homeassistant.helpers.service._load_services_files",
side_effect=service._load_services_files,
) as proxy_load_services_files,
patch(
"annotatedyaml.loader.load_yaml",
side_effect=load_yaml,
) as mock_load_yaml,
):
descriptions = await service.async_get_all_descriptions(hass)
mock_load_yaml.assert_called_once_with("services.yaml", None)
assert proxy_load_services_files.mock_calls[0][1][0] == unordered(
[
await async_get_integration(hass, domain),
]
)
assert descriptions == {
"test_domain": {
"happy_time": {
"fields": {
"topic": {
"selector": {"text": {"multiple": False, "multiline": False}}
},
"duration": {
"default": 5,
"selector": {
"number": {
"min": 1.0,
"max": 300.0,
"unit_of_measurement": "seconds",
"step": 1.0,
"mode": "slider",
}
},
},
},
"description_placeholders": {"placeholder": "beer"},
}
}
}
async def test_register_with_mixed_case(hass: HomeAssistant) -> None:
"""Test registering a service with mixed case.
@@ -1838,6 +1926,37 @@ async def test_register_admin_service(
assert calls[0].context.user_id == hass_admin_user.id
async def test_register_admin_service_with_placeholders(
hass: HomeAssistant, hass_admin_user: MockUser
) -> None:
"""Test the register admin service with description placeholders."""
calls = []
async def mock_service(call):
calls.append(call)
service.async_register_admin_service(
hass,
"test",
"test",
mock_service,
description_placeholders={"test_placeholder": "beer"},
)
await hass.services.async_call(
"test",
"test",
{},
blocking=True,
context=Context(user_id=hass_admin_user.id),
)
assert len(calls) == 1
descriptions = await service.async_get_all_descriptions(hass)
assert descriptions["test"]["test"]["description_placeholders"] == {
"test_placeholder": "beer"
}
@pytest.mark.parametrize(
"supports_response",
[SupportsResponse.ONLY, SupportsResponse.OPTIONAL],
@@ -2647,6 +2766,13 @@ async def test_register_platform_entity_service(
entity_domain="mock_integration",
schema={},
func=handle_service,
description_placeholders={"test_placeholder": "beer"},
)
descriptions = await service.async_get_all_descriptions(hass)
assert (
descriptions["mock_platform"]["hello"]["description_placeholders"]
== {"test_placeholder": "beer"}
== {"test_placeholder": "beer"}
)
await hass.services.async_call(