diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index fc38b821eee..a48f0133e84 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -85,6 +85,7 @@ from . import ( entity_registry, floor_registry as fr, issue_registry, + label_registry, location as loc_helper, ) from .singleton import singleton @@ -1560,6 +1561,92 @@ def area_devices(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries] +def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None]: + """Return all labels, or those from a area ID, device ID, or entity ID.""" + label_reg = label_registry.async_get(hass) + if lookup_value is None: + return [label.label_id for label in label_reg.async_list_labels()] + + ent_reg = entity_registry.async_get(hass) + + # Import here, not at top-level to avoid circular import + from . import config_validation as cv # pylint: disable=import-outside-toplevel + + lookup_value = str(lookup_value) + + try: + cv.entity_id(lookup_value) + except vol.Invalid: + pass + else: + if entity := ent_reg.async_get(lookup_value): + return list(entity.labels) + + # Check if this could be a device ID + dev_reg = device_registry.async_get(hass) + if device := dev_reg.async_get(lookup_value): + return list(device.labels) + + # Check if this could be a area ID + area_reg = area_registry.async_get(hass) + if area := area_reg.async_get_area(lookup_value): + return list(area.labels) + + return [] + + +def label_id(hass: HomeAssistant, lookup_value: Any) -> str | None: + """Get the label ID from a label name.""" + label_reg = label_registry.async_get(hass) + if label := label_reg.async_get_label_by_name(str(lookup_value)): + return label.label_id + return None + + +def label_name(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the label name from a label ID.""" + label_reg = label_registry.async_get(hass) + if label := label_reg.async_get_label(lookup_value): + return label.name + return None + + +def _label_id_or_name(hass: HomeAssistant, label_id_or_name: str) -> str | None: + """Get the label ID from a label name or ID.""" + # If label_name returns a value, we know the input was an ID, otherwise we + # assume it's a name, and if it's neither, we return early. + if label_name(hass, label_id_or_name) is not None: + return label_id_or_name + return label_id(hass, label_id_or_name) + + +def label_areas(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: + """Return areas for a given label ID or name.""" + if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: + return [] + area_reg = area_registry.async_get(hass) + entries = area_registry.async_entries_for_label(area_reg, _label_id) + return [entry.id for entry in entries] + + +def label_devices(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: + """Return device IDs for a given label ID or name.""" + if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: + return [] + dev_reg = device_registry.async_get(hass) + entries = device_registry.async_entries_for_label(dev_reg, _label_id) + return [entry.id for entry in entries] + + +def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: + """Return entities for a given label ID or name.""" + if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: + return [] + ent_reg = entity_registry.async_get(hass) + entries = entity_registry.async_entries_for_label(ent_reg, _label_id) + return [entry.entity_id for entry in entries] + + def closest(hass, *args): """Find closest entity. @@ -2731,6 +2818,24 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] + self.globals["labels"] = hassfunction(labels) + self.filters["labels"] = self.globals["labels"] + + self.globals["label_id"] = hassfunction(label_id) + self.filters["label_id"] = self.globals["label_id"] + + self.globals["label_name"] = hassfunction(label_name) + self.filters["label_name"] = self.globals["label_name"] + + self.globals["label_areas"] = hassfunction(label_areas) + self.filters["label_areas"] = self.globals["label_areas"] + + self.globals["label_devices"] = hassfunction(label_devices) + self.filters["label_devices"] = self.globals["label_devices"] + + self.globals["label_entities"] = hassfunction(label_entities) + self.filters["label_entities"] = self.globals["label_entities"] + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -2764,6 +2869,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "floor_name", "relative_time", "today_at", + "label_id", + "label_name", ] hass_filters = [ "closest", @@ -2774,6 +2881,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "floor_id", "floor_name", "has_value", + "label_id", + "label_name", ] hass_tests = [ "has_value", diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 211877bca6b..6f455c3dda4 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -40,6 +40,7 @@ from homeassistant.helpers import ( entity_registry as er, floor_registry as fr, issue_registry as ir, + label_registry as lr, template, translation, ) @@ -5309,3 +5310,363 @@ async def test_floor_areas( info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_areas }}}}") assert_result_info(info, [area.id]) assert info.rate_limit is None + + +async def test_labels( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test labels function.""" + + # Test no labels + info = render_to_info(hass, "{{ labels() }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test one label + label1 = label_registry.async_create("label1") + info = render_to_info(hass, "{{ labels() }}") + assert_result_info(info, [label1.label_id]) + assert info.rate_limit is None + + # Test multiple label + label2 = label_registry.async_create("label2") + info = render_to_info(hass, "{{ labels() }}") + assert_result_info(info, [label1.label_id, label2.label_id]) + assert info.rate_limit is None + + # Test non-exsting entity ID + info = render_to_info(hass, "{{ labels('sensor.fake') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'sensor.fake' | labels }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test non existing device ID (hex value) + info = render_to_info(hass, "{{ labels('123abc') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '123abc' | labels }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create a device & entity for testing + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Test entity, which has no labels + info = render_to_info(hass, f"{{{{ labels('{entity_entry.entity_id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{entity_entry.entity_id}' | labels }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test device, which has no labels + info = render_to_info(hass, f"{{{{ labels('{device_entry.id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | labels }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Add labels to the entity & device + device_entry = device_registry.async_update_device( + device_entry.id, labels=[label1.label_id] + ) + entity_entry = entity_registry.async_update_entity( + entity_entry.entity_id, labels=[label2.label_id] + ) + + # Test entity, which now has a label + info = render_to_info(hass, f"{{{{ '{entity_entry.entity_id}' | labels }}}}") + assert_result_info(info, [label2.label_id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{entity_entry.entity_id}') }}}}") + assert_result_info(info, [label2.label_id]) + assert info.rate_limit is None + + # Test device, which now has a label + info = render_to_info(hass, f"{{{{ '{device_entry.id}' | labels }}}}") + assert_result_info(info, [label1.label_id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{device_entry.id}') }}}}") + assert_result_info(info, [label1.label_id]) + assert info.rate_limit is None + + # Create area for testing + area = area_registry.async_create("living room") + + # Test area, which has no labels + info = render_to_info(hass, f"{{{{ '{area.id}' | labels }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{area.id}') }}}}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Add label to the area + area_registry.async_update(area.id, labels=[label1.label_id, label2.label_id]) + + # Test area, which now has labels + info = render_to_info(hass, f"{{{{ '{area.id}' | labels }}}}") + assert_result_info(info, [label1.label_id, label2.label_id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ labels('{area.id}') }}}}") + assert_result_info(info, [label1.label_id, label2.label_id]) + assert info.rate_limit is None + + +async def test_label_id( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_id function.""" + # Test non existing label name + info = render_to_info(hass, "{{ label_id('non-existing label') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'non-existing label' | label_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_id(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test with an actual label + label = label_registry.async_create("existing label") + info = render_to_info(hass, "{{ label_id('existing label') }}") + assert_result_info(info, label.label_id) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'existing label' | label_id }}") + assert_result_info(info, label.label_id) + assert info.rate_limit is None + + +async def test_label_name( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_name function.""" + # Test non existing label ID + info = render_to_info(hass, "{{ label_name('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '1234567890' | label_name }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_name(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_name }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing label ID + label = label_registry.async_create("choo choo") + info = render_to_info(hass, f"{{{{ label_name('{label.label_id}') }}}}") + assert_result_info(info, label.name) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_name }}}}") + assert_result_info(info, label.name) + assert info.rate_limit is None + + +async def test_label_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_entities function.""" + + # Test non existing device ID + info = render_to_info(hass, "{{ label_entities('deadbeef') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'deadbeef' | label_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create a fake config entry with a entity + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + ) + + # Add a label to the entity + label = label_registry.async_create("Romantic Lights") + entity_registry.async_update_entity(entity_entry.entity_id, labels={label.label_id}) + + # Get entities by label ID + info = render_to_info(hass, f"{{{{ label_entities('{label.label_id}') }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_entities }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + # Get entities by label name + info = render_to_info(hass, f"{{{{ label_entities('{label.name}') }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.name}' | label_entities }}}}") + assert_result_info(info, ["light.hue_5678"]) + assert info.rate_limit is None + + +async def test_label_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + label_registry: ar.AreaRegistry, +) -> None: + """Test label_devices function.""" + + # Test non existing device ID + info = render_to_info(hass, "{{ label_devices('deadbeef') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'deadbeef' | label_devices }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_devices(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_devices }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create a fake config entry with a device + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + # Add a label to it + label = label_registry.async_create("Romantic Lights") + device_registry.async_update_device(device_entry.id, labels=[label.label_id]) + + # Get the devices from a label by its ID + info = render_to_info(hass, f"{{{{ label_devices('{label.label_id}') }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_devices }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + # Get the devices from a label by its name + info = render_to_info(hass, f"{{{{ label_devices('{label.name}') }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.name}' | label_devices }}}}") + assert_result_info(info, [device_entry.id]) + assert info.rate_limit is None + + +async def test_label_areas( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_areas function.""" + + # Test non existing area ID + info = render_to_info(hass, "{{ label_areas('deadbeef') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'deadbeef' | label_areas }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_areas(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_areas }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Create an area with an label + label = label_registry.async_create("Upstairs") + master_bedroom = area_registry.async_create( + "Master Bedroom", labels=[label.label_id] + ) + + # Get areas by label ID + info = render_to_info(hass, f"{{{{ label_areas('{label.label_id}') }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_areas }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None + + # Get areas by label name + info = render_to_info(hass, f"{{{{ label_areas('{label.name}') }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.name}' | label_areas }}}}") + assert_result_info(info, [master_bedroom.id]) + assert info.rate_limit is None