Skip to content

Commit c2b7a63

Browse files
authored
Add get_services_for_target websocket command (home-assistant#157334)
1 parent 550716a commit c2b7a63

File tree

3 files changed

+281
-2
lines changed

3 files changed

+281
-2
lines changed

homeassistant/components/websocket_api/automation.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
get_device_class,
1515
get_supported_features,
1616
)
17+
from homeassistant.helpers.service import (
18+
async_get_all_descriptions as async_get_all_service_descriptions,
19+
)
1720
from homeassistant.helpers.trigger import (
1821
async_get_all_descriptions as async_get_all_trigger_descriptions,
1922
)
@@ -194,3 +197,19 @@ async def async_get_triggers_for_target(
194197
return _async_get_automation_components_for_target(
195198
hass, target_selector, expand_group, descriptions
196199
)
200+
201+
202+
async def async_get_services_for_target(
203+
hass: HomeAssistant, target_selector: ConfigType, expand_group: bool
204+
) -> set[str]:
205+
"""Get services for a target."""
206+
descriptions = await async_get_all_service_descriptions(hass)
207+
# Flatten dicts to be keyed by domain.name to match trigger/condition format
208+
descriptions_flatten = {
209+
f"{domain}.{service_name}": desc
210+
for domain, services in descriptions.items()
211+
for service_name, desc in services.items()
212+
}
213+
return _async_get_automation_components_for_target(
214+
hass, target_selector, expand_group, descriptions_flatten
215+
)

homeassistant/components/websocket_api/commands.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
from homeassistant.util.json import format_unserializable_data
8888

8989
from . import const, decorators, messages
90-
from .automation import async_get_triggers_for_target
90+
from .automation import async_get_services_for_target, async_get_triggers_for_target
9191
from .connection import ActiveConnection
9292
from .messages import construct_event_message, construct_result_message
9393

@@ -108,11 +108,12 @@ def async_register_commands(
108108
async_reg(hass, handle_entity_source)
109109
async_reg(hass, handle_execute_script)
110110
async_reg(hass, handle_extract_from_target)
111-
async_reg(hass, handle_get_triggers_for_target)
112111
async_reg(hass, handle_fire_event)
113112
async_reg(hass, handle_get_config)
114113
async_reg(hass, handle_get_services)
114+
async_reg(hass, handle_get_services_for_target)
115115
async_reg(hass, handle_get_states)
116+
async_reg(hass, handle_get_triggers_for_target)
116117
async_reg(hass, handle_manifest_get)
117118
async_reg(hass, handle_integration_setup_info)
118119
async_reg(hass, handle_manifest_list)
@@ -902,6 +903,29 @@ async def handle_get_triggers_for_target(
902903
connection.send_result(msg["id"], triggers)
903904

904905

906+
@decorators.websocket_command(
907+
{
908+
vol.Required("type"): "get_services_for_target",
909+
vol.Required("target"): cv.TARGET_FIELDS,
910+
vol.Optional("expand_group", default=True): bool,
911+
}
912+
)
913+
@decorators.async_response
914+
async def handle_get_services_for_target(
915+
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
916+
) -> None:
917+
"""Handle get services for target command.
918+
919+
This command returns all services that can be used with any entities that are currently
920+
part of a target.
921+
"""
922+
services = await async_get_services_for_target(
923+
hass, msg["target"], msg["expand_group"]
924+
)
925+
926+
connection.send_result(msg["id"], services)
927+
928+
905929
@decorators.websocket_command(
906930
{
907931
vol.Required("type"): "subscribe_trigger",

tests/components/websocket_api/test_commands.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3904,3 +3904,239 @@ async def assert_triggers(target: dict[str, list[str]], expected: list[str]) ->
39043904
"switch.turned_on",
39053905
],
39063906
)
3907+
3908+
3909+
@pytest.mark.usefixtures("target_entities")
3910+
@patch("annotatedyaml.loader.load_yaml")
3911+
@patch.object(Integration, "has_services", return_value=True)
3912+
async def test_get_services_for_target(
3913+
mock_has_services: Mock,
3914+
mock_load_yaml: Mock,
3915+
hass: HomeAssistant,
3916+
websocket_client: MockHAClientWebSocket,
3917+
) -> None:
3918+
"""Test get_services_for_target command with mixed target types."""
3919+
3920+
def get_common_service_descriptions(domain: str):
3921+
return f"""
3922+
turn_on:
3923+
target:
3924+
entity:
3925+
domain: {domain}
3926+
fields:
3927+
behavior:
3928+
required: true
3929+
default: any
3930+
selector:
3931+
select:
3932+
options:
3933+
- first
3934+
- last
3935+
- any
3936+
non_target_service:
3937+
fields:
3938+
behavior:
3939+
required: true
3940+
default: any
3941+
selector:
3942+
select:
3943+
options:
3944+
- first
3945+
- last
3946+
- any
3947+
"""
3948+
3949+
component1_service_descriptions = """
3950+
light_message:
3951+
target:
3952+
entity:
3953+
- domain: light
3954+
integration: component1
3955+
- domain: sensor
3956+
integration: component1
3957+
device_class: illuminance
3958+
light_flash:
3959+
target:
3960+
entity:
3961+
domain: light
3962+
integration: component1
3963+
supported_features:
3964+
- light.LightEntityFeature.FLASH
3965+
- light.LightEntityFeature.EFFECT
3966+
light_dance:
3967+
target:
3968+
entity:
3969+
domain: light
3970+
integration: component1
3971+
supported_features: # both required features
3972+
- - light.LightEntityFeature.FLASH
3973+
- light.LightEntityFeature.TRANSITION
3974+
"""
3975+
3976+
component2_service_descriptions = """
3977+
match_all:
3978+
target:
3979+
3980+
other_integration_lights:
3981+
target:
3982+
entity:
3983+
- domain: light
3984+
supported_features:
3985+
- light.LightEntityFeature.EFFECT
3986+
- integration: test
3987+
domain: light
3988+
"""
3989+
3990+
def _load_yaml(fname, secrets=None):
3991+
if fname.endswith("component1/services.yaml"):
3992+
service_descriptions = component1_service_descriptions
3993+
elif fname.endswith("component2/services.yaml"):
3994+
service_descriptions = component2_service_descriptions
3995+
else:
3996+
service_descriptions = get_common_service_descriptions(fname.split("/")[-2])
3997+
with io.StringIO(service_descriptions) as file:
3998+
return parse_yaml(file)
3999+
4000+
mock_load_yaml.side_effect = _load_yaml
4001+
mock_integration(hass, MockModule("light"))
4002+
assert await async_setup_component(hass, "light", {})
4003+
mock_integration(hass, MockModule("switch"))
4004+
assert await async_setup_component(hass, "switch", {})
4005+
mock_integration(hass, MockModule("sensor"))
4006+
assert await async_setup_component(hass, "sensor", {})
4007+
mock_integration(hass, MockModule("component1"))
4008+
assert await async_setup_component(hass, "component1", {})
4009+
mock_integration(hass, MockModule("component2"))
4010+
assert await async_setup_component(hass, "component2", {})
4011+
await hass.async_block_till_done()
4012+
4013+
hass.services.async_register("light", "turn_on", lambda call: None)
4014+
hass.services.async_register("light", "non_target_service", lambda call: None)
4015+
hass.services.async_register("switch", "turn_on", lambda call: None)
4016+
hass.services.async_register("switch", "non_target_service", lambda call: None)
4017+
hass.services.async_register("sensor", "turn_on", lambda call: None)
4018+
hass.services.async_register("sensor", "non_target_service", lambda call: None)
4019+
hass.services.async_register("component1", "light_message", lambda call: None)
4020+
hass.services.async_register("component1", "light_flash", lambda call: None)
4021+
hass.services.async_register("component1", "light_dance", lambda call: None)
4022+
hass.services.async_register("component2", "match_all", lambda call: None)
4023+
hass.services.async_register(
4024+
"component2", "other_integration_lights", lambda call: None
4025+
)
4026+
await hass.async_block_till_done()
4027+
4028+
async def assert_services(target: dict[str, list[str]], expected: list[str]) -> Any:
4029+
"""Call the command and assert expected services."""
4030+
await websocket_client.send_json_auto_id(
4031+
{"type": "get_services_for_target", "target": target}
4032+
)
4033+
msg = await websocket_client.receive_json()
4034+
4035+
assert msg["type"] == const.TYPE_RESULT
4036+
assert msg["success"]
4037+
assert sorted(msg["result"]) == sorted(expected)
4038+
4039+
# Test entity target - unknown entity
4040+
await assert_services({"entity_id": ["light.unknown_entity"]}, [])
4041+
4042+
# Test entity target - entity not in registry
4043+
await assert_services(
4044+
{"entity_id": ["light.not_registry"]},
4045+
[
4046+
"component2.match_all",
4047+
"component2.other_integration_lights",
4048+
"light.turn_on",
4049+
],
4050+
)
4051+
4052+
# Test entity targets
4053+
await assert_services(
4054+
{"entity_id": ["light.component1_light", "switch.component1_switch"]},
4055+
[
4056+
"component1.light_message",
4057+
"component2.match_all",
4058+
"light.turn_on",
4059+
"switch.turn_on",
4060+
],
4061+
)
4062+
await assert_services(
4063+
{"entity_id": ["light.component1_flash_light"]},
4064+
[
4065+
"component1.light_flash",
4066+
"component1.light_message",
4067+
"component2.match_all",
4068+
"light.turn_on",
4069+
],
4070+
)
4071+
await assert_services(
4072+
{"entity_id": ["light.component1_effect_flash_light"]},
4073+
[
4074+
"component1.light_flash",
4075+
"component1.light_message",
4076+
"component2.match_all",
4077+
"component2.other_integration_lights",
4078+
"light.turn_on",
4079+
],
4080+
)
4081+
await assert_services(
4082+
{"entity_id": ["light.component1_flash_transition_light"]},
4083+
[
4084+
"component1.light_dance",
4085+
"component1.light_flash",
4086+
"component1.light_message",
4087+
"component2.match_all",
4088+
"light.turn_on",
4089+
],
4090+
)
4091+
4092+
# Test device target - multiple devices
4093+
await assert_services(
4094+
{"device_id": ["device1", "device2"]},
4095+
[
4096+
"component1.light_message",
4097+
"component2.match_all",
4098+
"component2.other_integration_lights",
4099+
"light.turn_on",
4100+
"sensor.turn_on",
4101+
"switch.turn_on",
4102+
],
4103+
)
4104+
4105+
# Test area target - multiple areas
4106+
await assert_services(
4107+
{"area_id": ["kitchen", "living_room"]},
4108+
[
4109+
"component2.match_all",
4110+
"component2.other_integration_lights",
4111+
"light.turn_on",
4112+
"switch.turn_on",
4113+
],
4114+
)
4115+
4116+
# Test label target - multiple labels
4117+
await assert_services(
4118+
{"label_id": ["label_1", "label_2"]},
4119+
[
4120+
"light.turn_on",
4121+
"component2.match_all",
4122+
"component2.other_integration_lights",
4123+
"switch.turn_on",
4124+
],
4125+
)
4126+
# Test mixed target types
4127+
await assert_services(
4128+
{
4129+
"entity_id": ["light.test1"],
4130+
"device_id": ["device2"],
4131+
"area_id": ["kitchen"],
4132+
"label_id": ["label_1"],
4133+
},
4134+
[
4135+
"component1.light_message",
4136+
"component2.match_all",
4137+
"component2.other_integration_lights",
4138+
"light.turn_on",
4139+
"sensor.turn_on",
4140+
"switch.turn_on",
4141+
],
4142+
)

0 commit comments

Comments
 (0)