Add expand_target websocket command

This commit is contained in:
abmantis
2025-08-06 15:50:16 +01:00
parent 124e7cf4c8
commit 8308be185e
2 changed files with 303 additions and 2 deletions

View File

@@ -34,7 +34,12 @@ from homeassistant.exceptions import (
TemplateError, TemplateError,
Unauthorized, Unauthorized,
) )
from homeassistant.helpers import config_validation as cv, entity, template from homeassistant.helpers import (
config_validation as cv,
entity,
target as target_helpers,
template,
)
from homeassistant.helpers.condition import ( from homeassistant.helpers.condition import (
async_get_all_descriptions as async_get_all_condition_descriptions, async_get_all_descriptions as async_get_all_condition_descriptions,
async_subscribe_platform_events as async_subscribe_condition_platform_events, async_subscribe_platform_events as async_subscribe_condition_platform_events,
@@ -96,6 +101,7 @@ def async_register_commands(
async_reg(hass, handle_call_service) async_reg(hass, handle_call_service)
async_reg(hass, handle_entity_source) async_reg(hass, handle_entity_source)
async_reg(hass, handle_execute_script) async_reg(hass, handle_execute_script)
async_reg(hass, handle_expand_target)
async_reg(hass, handle_fire_event) async_reg(hass, handle_fire_event)
async_reg(hass, handle_get_config) async_reg(hass, handle_get_config)
async_reg(hass, handle_get_services) async_reg(hass, handle_get_services)
@@ -829,6 +835,40 @@ def handle_entity_source(
connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) connection.send_result(msg["id"], _serialize_entity_sources(entity_sources))
@callback
@decorators.websocket_command(
{
vol.Required("type"): "expand_target",
vol.Required("target"): cv.ENTITY_SERVICE_FIELDS,
vol.Optional("expand_group", default=False): bool,
}
)
def handle_expand_target(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle expand target command."""
selector_data = target_helpers.TargetSelectorData(msg["target"])
extracted = target_helpers.async_extract_referenced_entity_ids(
hass, selector_data, expand_group=msg["expand_group"]
)
extracted_dict = {
"referenced_entities": extracted.referenced.union(
extracted.indirectly_referenced
),
"referenced_devices": extracted.referenced_devices,
"referenced_areas": extracted.referenced_areas,
"missing_devices": extracted.missing_devices,
"missing_areas": extracted.missing_areas,
"missing_floors": extracted.missing_floors,
"missing_labels": extracted.missing_labels,
}
payload = json_bytes(extracted_dict)
connection.send_message(construct_result_message(msg["id"], payload))
@decorators.websocket_command( @decorators.websocket_command(
{ {
vol.Required("type"): "subscribe_trigger", vol.Required("type"): "subscribe_trigger",

View File

@@ -28,7 +28,12 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS
from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback from homeassistant.core import Context, HomeAssistant, State, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
label_registry as lr,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.loader import Integration, async_get_integration from homeassistant.loader import Integration, async_get_integration
@@ -104,6 +109,29 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None:
del state_dict[STATE_KEY_LONG_NAMES[key]][item] del state_dict[STATE_KEY_LONG_NAMES[key]][item]
def _assert_expand_target_command_result(
msg: dict[str, Any],
entities: set[str] | None = None,
devices: set[str] | None = None,
areas: set[str] | None = None,
missing_devices: set[str] | None = None,
missing_areas: set[str] | None = None,
missing_labels: set[str] | None = None,
missing_floors: set[str] | None = None,
) -> None:
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
result = msg["result"]
assert set(result["referenced_entities"]) == (entities or set())
assert set(result["referenced_devices"]) == (devices or set())
assert set(result["referenced_areas"]) == (areas or set())
assert set(result["missing_devices"]) == (missing_devices or set())
assert set(result["missing_areas"]) == (missing_areas or set())
assert set(result["missing_floors"]) == (missing_floors or set())
assert set(result["missing_labels"]) == (missing_labels or set())
async def test_fire_event( async def test_fire_event(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None: ) -> None:
@@ -3115,3 +3143,236 @@ async def test_wait_integration_startup(
# The component has been loaded # The component has been loaded
assert "test" in hass.config.components assert "test" in hass.config.components
async def test_expand_target(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
label_registry: lr.LabelRegistry,
) -> None:
"""Test expand_target command with mixed target types including entities, devices, areas, and labels."""
async def call_command(target: dict[str, str]) -> Any:
await websocket_client.send_json_auto_id(
{"type": "expand_target", "target": target}
)
return await websocket_client.receive_json()
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
device1 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device1")},
)
device2 = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device2")},
)
area_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device3")},
)
label2_device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test", "device4")},
)
kitchen_area = area_registry.async_create("Kitchen")
living_room_area = area_registry.async_create("Living Room")
label_area = area_registry.async_create("Bathroom")
label1 = label_registry.async_create("Test Label 1")
label2 = label_registry.async_create("Test Label 2")
# Associate devices with areas and labels
device_registry.async_update_device(area_device.id, area_id=kitchen_area.id)
device_registry.async_update_device(label2_device.id, labels={label2.label_id})
area_registry.async_update(label_area.id, labels={label1.label_id})
# Setup entities with targets
device1_entity1 = entity_registry.async_get_or_create(
"light", "test", "unique1", device_id=device1.id
)
device1_entity2 = entity_registry.async_get_or_create(
"switch", "test", "unique2", device_id=device1.id
)
device2_entity = entity_registry.async_get_or_create(
"sensor", "test", "unique3", device_id=device2.id
)
area_device_entity = entity_registry.async_get_or_create(
"light", "test", "unique4", device_id=area_device.id
)
area_entity = entity_registry.async_get_or_create("switch", "test", "unique5")
label_device_entity = entity_registry.async_get_or_create(
"light", "test", "unique6", device_id=label2_device.id
)
label_entity = entity_registry.async_get_or_create("switch", "test", "unique7")
# Associate entities with areas and labels
entity_registry.async_update_entity(
area_entity.entity_id, area_id=living_room_area.id
)
entity_registry.async_update_entity(
label_entity.entity_id, labels={label1.label_id}
)
msg = await call_command({"entity_id": ["light.unknown_entity"]})
_assert_expand_target_command_result(msg, entities={"light.unknown_entity"})
msg = await call_command({"device_id": [device1.id, device2.id]})
_assert_expand_target_command_result(
msg,
entities={
device1_entity1.entity_id,
device1_entity2.entity_id,
device2_entity.entity_id,
},
devices={device1.id, device2.id},
)
msg = await call_command({"area_id": [kitchen_area.id, living_room_area.id]})
_assert_expand_target_command_result(
msg,
entities={area_device_entity.entity_id, area_entity.entity_id},
areas={kitchen_area.id, living_room_area.id},
devices={area_device.id},
)
msg = await call_command({"label_id": [label1.label_id, label2.label_id]})
_assert_expand_target_command_result(
msg,
entities={label_device_entity.entity_id, label_entity.entity_id},
devices={label2_device.id},
areas={label_area.id},
)
# Test multiple mixed targets
msg = await call_command(
{
"entity_id": ["light.direct"],
"device_id": [device1.id],
"area_id": [kitchen_area.id],
"label_id": [label1.label_id],
},
)
_assert_expand_target_command_result(
msg,
entities={
"light.direct",
device1_entity1.entity_id,
device1_entity2.entity_id,
area_device_entity.entity_id,
label_entity.entity_id,
},
devices={device1.id, area_device.id},
areas={kitchen_area.id, label_area.id},
)
async def test_expand_target_expand_group(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None:
"""Test expand_target command with expand_group parameter."""
await async_setup_component(
hass,
"group",
{
"group": {
"test_group": {
"name": "Test Group",
"entities": ["light.kitchen", "light.living_room"],
}
}
},
)
hass.states.async_set("light.kitchen", "on")
hass.states.async_set("light.living_room", "off")
# Test without expand_group (default False)
await websocket_client.send_json_auto_id(
{
"type": "expand_target",
"target": {"entity_id": ["group.test_group"]},
}
)
msg = await websocket_client.receive_json()
_assert_expand_target_command_result(msg, entities={"group.test_group"})
# Test with expand_group=True
await websocket_client.send_json_auto_id(
{
"type": "expand_target",
"target": {"entity_id": ["group.test_group"]},
"expand_group": True,
}
)
msg = await websocket_client.receive_json()
_assert_expand_target_command_result(
msg,
entities={"light.kitchen", "light.living_room"},
)
async def test_expand_target_missing_entities(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None:
"""Test expand_target command with missing device IDs, area IDs, etc."""
await websocket_client.send_json_auto_id(
{
"type": "expand_target",
"target": {
"device_id": ["non_existent_device"],
"area_id": ["non_existent_area"],
"label_id": ["non_existent_label"],
},
}
)
msg = await websocket_client.receive_json()
# Non-existent devices/areas are still referenced but reported as missing
_assert_expand_target_command_result(
msg,
devices={"non_existent_device"},
areas={"non_existent_area"},
missing_areas={"non_existent_area"},
missing_devices={"non_existent_device"},
missing_labels={"non_existent_label"},
)
async def test_expand_target_empty_target(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None:
"""Test expand_target command with empty target."""
await websocket_client.send_json_auto_id(
{
"type": "expand_target",
"target": {},
}
)
msg = await websocket_client.receive_json()
_assert_expand_target_command_result(msg)
async def test_expand_target_validation_error(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None:
"""Test expand_target command with invalid target data."""
await websocket_client.send_json_auto_id(
{
"type": "expand_target",
"target": "invalid", # Should be a dict, not string
}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert "error" in msg