From f7ca2fcc4605c0c5eba5e2d1420de2c5a4ed5e4f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 24 Aug 2026 12:26:31 -0500 Subject: [PATCH] Fix intents targeting unexposed entities and non-light entities in HassLightSet (#180026) Co-authored-by: Claude Opus 5 --- homeassistant/components/light/intent.py | 1 + .../components/wyoming/conversation.py | 1 + tests/components/light/test_intent.py | 85 ++++++++++++++++++- tests/components/wyoming/test_conversation.py | 2 + 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 67f51fc314fc..a15c085b2953 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -45,6 +45,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: ), }, description="Sets the brightness percentage or color of a light", + required_domains={DOMAIN}, platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index f70baf7ce0ca..dea55043fd6c 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -272,6 +272,7 @@ class WyomingConversationEntity( intent_slots, text_input=user_input.text, language=user_input.language, + assistant=conversation.DOMAIN, satellite_id=user_input.satellite_id, device_id=user_input.device_id, ) diff --git a/tests/components/light/test_intent.py b/tests/components/light/test_intent.py index 1f5a9e7ce274..15617bba62eb 100644 --- a/tests/components/light/test_intent.py +++ b/tests/components/light/test_intent.py @@ -1,13 +1,20 @@ """Tests for the light intents.""" +import pytest + from homeassistant.components import light +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode, intent from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.intent import async_handle +from homeassistant.helpers import area_registry as ar, entity_registry as er +from homeassistant.helpers.intent import MatchFailedError, async_handle +from homeassistant.setup import async_setup_component from tests.common import async_mock_service +ASSISTANT = "conversation" + async def test_intent_set_color(hass: HomeAssistant) -> None: """Test the set color intent.""" @@ -89,3 +96,79 @@ async def test_intent_set_temperature(hass: HomeAssistant) -> None: assert call.service == SERVICE_TURN_ON assert call.data.get(ATTR_ENTITY_ID) == "light.test" assert call.data.get(light.ATTR_COLOR_TEMP_KELVIN) == 2000 + + +async def test_intent_set_area_only_targets_lights( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that only lights are targeted when an area is used without a domain slot.""" + kitchen = area_registry.async_create("Kitchen") + for domain, unique_id in (("light", "l1"), ("switch", "s1"), ("sensor", "t1")): + entry = entity_registry.async_get_or_create(domain, "test", unique_id) + entity_registry.async_update_entity(entry.entity_id, area_id=kitchen.id) + hass.states.async_set(entry.entity_id, "off") + + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + await async_handle( + hass, + "test", + intent.INTENT_SET, + {"area": {"value": "Kitchen"}, "brightness": {"value": "20"}}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data.get(ATTR_ENTITY_ID) == "light.test_l1" + assert calls[0].data.get(light.ATTR_BRIGHTNESS_PCT) == 20 + + +async def test_intent_set_without_name_or_area_skips_unexposed( + hass: HomeAssistant, +) -> None: + """Test that unexposed lights are not targeted when no name or area is given.""" + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.exposed", "off") + hass.states.async_set("light.hidden", "off") + async_expose_entity(hass, ASSISTANT, "light.hidden", False) + + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + await async_handle( + hass, + "test", + intent.INTENT_SET, + {"brightness": {"value": "20"}}, + assistant=ASSISTANT, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data.get(ATTR_ENTITY_ID) == "light.exposed" + + +async def test_intent_set_without_name_or_area_all_unexposed( + hass: HomeAssistant, +) -> None: + """Test that no lights are targeted when none are exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + hass.states.async_set("light.hidden", "off") + async_expose_entity(hass, ASSISTANT, "light.hidden", False) + + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + with pytest.raises(MatchFailedError): + await async_handle( + hass, + "test", + intent.INTENT_SET, + {"brightness": {"value": "20"}}, + assistant=ASSISTANT, + ) + + assert not calls diff --git a/tests/components/wyoming/test_conversation.py b/tests/components/wyoming/test_conversation.py index d210328cd583..c96f185de363 100644 --- a/tests/components/wyoming/test_conversation.py +++ b/tests/components/wyoming/test_conversation.py @@ -64,6 +64,8 @@ async def test_intent( assert intent_obj.slots.get("entity", {}).get("value") == "value" assert intent_obj.satellite_id == satellite_id assert intent_obj.device_id == device_id + # Entities must be filtered by exposure to the conversation assistant + assert intent_obj.assistant == conversation.DOMAIN response = intent_obj.create_response() # Add parts to test response rendering