From fc946f747e202b0c63297882a46b714f2228c5b1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Aug 2026 22:56:19 +0200 Subject: [PATCH] Allow linking template helpers to child devices (#179590) --- homeassistant/components/template/entity.py | 11 +++-- homeassistant/components/template/repairs.py | 6 +-- tests/components/template/test_init.py | 50 ++++++++++++++++++++ tests/components/template/test_repairs.py | 47 ++++++++++++++++++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index e2c2709b84ee..85d19f013b1f 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -88,12 +88,13 @@ class AbstractTemplateEntity(Entity): ) device_registry = dr.async_get(hass) + # Allow linking to a main or child device, but not to a composite device. if ( - device_id := config.get(CONF_DEVICE_ID) - ) is not None and device_registry.async_is_composite_device_id( - device_id - ) is False: - self.device_entry = device_registry.async_get(device_id) + (device_id := config.get(CONF_DEVICE_ID)) is not None + and (device_entry := device_registry.async_get(device_id)) is not None + and not device_registry.async_is_composite_device_id(device_id) + ): + self.device_entry = device_entry @property @abstractmethod diff --git a/homeassistant/components/template/repairs.py b/homeassistant/components/template/repairs.py index 3a95eb11a349..c7e308eee879 100644 --- a/homeassistant/components/template/repairs.py +++ b/homeassistant/components/template/repairs.py @@ -41,9 +41,9 @@ class CompositeDeviceIdRepairFlow(RepairsFlow): errors: dict[str, str] = {} if user_input is not None: device_id = user_input.get(CONF_DEVICE_ID) - if ( - device_id is None - or device_registry.async_is_composite_device_id(device_id) is False + if device_id is None or ( + device_registry.async_get(device_id) is not None + and not device_registry.async_is_composite_device_id(device_id) ): options = {**entry.options} if device_id: diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index af518f5a7b30..8727784f0827 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -492,6 +492,56 @@ async def test_change_device( ) +@pytest.mark.parametrize( + "linked_device", + [ + pytest.param("main", id="main_device"), + pytest.param("child", id="child_device"), + ], +) +async def test_link_to_main_or_child_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + linked_device: str, +) -> None: + """Test a template entity links to a selected main or child device.""" + source_entry = MockConfigEntry() + source_entry.add_to_hass(hass) + main_device = device_registry.async_get_or_create( + config_entry_id=source_entry.entry_id, + identifiers={("test", "main")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=source_entry.entry_id, + identifiers={("test", "child")}, + parent_device_id=main_device.id, + ) + selected_device_id = {"main": main_device, "child": child_device}[linked_device].id + + template_config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "name": "My template", + "state": "{{10}}", + "template_type": "sensor", + "device_id": selected_device_id, + }, + title="Template", + ) + template_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entities = list( + entity_registry.entities.get_entries_for_config_entry_id( + template_config_entry.entry_id + ) + ) + assert len(template_entities) == 1 + assert template_entities[0].device_id == selected_device_id + + async def test_setup_removes_stale_helper_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/template/test_repairs.py b/tests/components/template/test_repairs.py index 5e5f004abcd6..1be7befffd1e 100644 --- a/tests/components/template/test_repairs.py +++ b/tests/components/template/test_repairs.py @@ -176,6 +176,53 @@ async def test_composite_device_id_repair_flow( assert entity_entry.device_id == picked_device_id +@pytest.mark.usefixtures("split_devices") +async def test_composite_device_id_repair_flow_links_child_device( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the repair flow accepts a child device and links the entity to it.""" + source_entry = MockConfigEntry(domain="itg3") + source_entry.add_to_hass(hass) + parent_device = device_registry.async_get_or_create( + config_entry_id=source_entry.entry_id, + identifiers={("itg3", "parent")}, + ) + child_device = device_registry.async_get_or_create_child( + config_entry_id=source_entry.entry_id, + identifiers={("itg3", "child")}, + parent_device_id=parent_device.id, + ) + + entry = await _setup_template_entry(hass, COMPOSITE_ID) + issue_id = f"composite_device_id_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + client = await hass_client() + + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_device" + + result = await process_repair_fix_flow( + client, result["flow_id"], json={CONF_DEVICE_ID: child_device.id} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + assert entry.options[CONF_DEVICE_ID] == child_device.id + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + entity_entry = entity_registry.async_get(TEMPLATE_ENTITY_ID) + assert entity_entry is not None + assert entity_entry.device_id == child_device.id + + async def test_composite_device_id_repair_flow_ambiguity_not_resolved( hass: HomeAssistant, hass_client: ClientSessionGenerator,