diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 2ec898bfb0e..55c4947fe4a 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -5,9 +5,16 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_TYPE -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, intent, script, template +from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import ( + config_validation as cv, + intent, + script, + service, + template, +) +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -55,10 +62,27 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the intent script component.""" - intents = config[DOMAIN] +async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None: + """Handle start Intent Script service call.""" + new_config = await async_integration_yaml_config(hass, DOMAIN) + existing_intents = hass.data[DOMAIN] + + for intent_type in existing_intents: + intent.async_remove(hass, intent_type) + + if not new_config or DOMAIN not in new_config: + hass.data[DOMAIN] = {} + return + + new_intents = new_config[DOMAIN] + + async_load_intents(hass, new_intents) + + +def async_load_intents(hass: HomeAssistant, intents: dict): + """Load YAML intents into the intent system.""" template.attach(hass, intents) + hass.data[DOMAIN] = intents for intent_type, conf in intents.items(): if CONF_ACTION in conf: @@ -67,6 +91,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the intent script component.""" + intents = config[DOMAIN] + + async_load_intents(hass, intents) + + async def _handle_reload(servie_call: ServiceCall) -> None: + return await async_reload(hass, servie_call) + + service.async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + _handle_reload, + ) + return True diff --git a/homeassistant/components/intent_script/services.yaml b/homeassistant/components/intent_script/services.yaml new file mode 100644 index 00000000000..bb981dbc69c --- /dev/null +++ b/homeassistant/components/intent_script/services.yaml @@ -0,0 +1,3 @@ +reload: + name: Reload + description: Reload the intent_script configuration. diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 8b07c2adc9a..f2b29c0040b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -57,6 +57,16 @@ def async_register(hass: HomeAssistant, handler: IntentHandler) -> None: intents[handler.intent_type] = handler +@callback +@bind_hass +def async_remove(hass: HomeAssistant, intent_type: str) -> None: + """Remove an intent from Home Assistant.""" + if (intents := hass.data.get(DATA_KEY)) is None: + return + + intents.pop(intent_type, None) + + @bind_hass async def async_handle( hass: HomeAssistant, diff --git a/tests/components/intent_script/fixtures/configuration.yaml b/tests/components/intent_script/fixtures/configuration.yaml new file mode 100644 index 00000000000..93b4ebe5e26 --- /dev/null +++ b/tests/components/intent_script/fixtures/configuration.yaml @@ -0,0 +1,4 @@ +intent_script: + NewIntent2: + speech: + text: Hello World diff --git a/tests/components/intent_script/fixtures/configuration_no_entry.yaml b/tests/components/intent_script/fixtures/configuration_no_entry.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 044bbcaa7e7..a68b2a9be24 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -1,9 +1,14 @@ """Test intent_script component.""" +from unittest.mock import patch + +from homeassistant import config as hass_config from homeassistant.bootstrap import async_setup_component +from homeassistant.components.intent_script import DOMAIN +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from tests.common import async_mock_service +from tests.common import async_mock_service, get_fixture_path async def test_intent_script(hass: HomeAssistant) -> None: @@ -134,3 +139,49 @@ async def test_intent_script_falsy_reprompt(hass: HomeAssistant) -> None: assert response.card["simple"]["title"] == "Hello Paulus" assert response.card["simple"]["content"] == "Content for Paulus" + + +async def test_reload(hass: HomeAssistant) -> None: + """Verify we can reload intent config.""" + + config = {"intent_script": {"NewIntent1": {"speech": {"text": "HelloWorld123"}}}} + + await async_setup_component(hass, "intent_script", config) + await hass.async_block_till_done() + + intents = hass.data.get(intent.DATA_KEY) + + assert len(intents) == 1 + assert intents.get("NewIntent1") + + yaml_path = get_fixture_path("configuration.yaml", "intent_script") + + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(intents) == 1 + + assert intents.get("NewIntent1") is None + assert intents.get("NewIntent2") + + yaml_path = get_fixture_path("configuration_no_entry.yaml", "intent_script") + + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # absence of intent_script from the configuration.yaml should delete all intents. + assert len(intents) == 0 + assert intents.get("NewIntent1") is None + assert intents.get("NewIntent2") is None diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index edc6a281172..f3256e90b62 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,4 +1,6 @@ """Tests for the intent helpers.""" +from unittest.mock import MagicMock, patch + import pytest import voluptuous as vol @@ -184,3 +186,63 @@ async def test_cant_turn_on_lock(hass: HomeAssistant) -> None: assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH + + +def test_async_register(hass: HomeAssistant) -> None: + """Test registering an intent and verifying it is stored correctly.""" + handler = MagicMock() + handler.intent_type = "test_intent" + + intent.async_register(hass, handler) + + assert hass.data[intent.DATA_KEY]["test_intent"] == handler + + +def test_async_register_overwrite(hass: HomeAssistant) -> None: + """Test registering multiple intents with the same type, ensuring the last one overwrites the previous one and a warning is emitted.""" + handler1 = MagicMock() + handler1.intent_type = "test_intent" + + handler2 = MagicMock() + handler2.intent_type = "test_intent" + + with patch.object(intent._LOGGER, "warning") as mock_warning: + intent.async_register(hass, handler1) + intent.async_register(hass, handler2) + + mock_warning.assert_called_once_with( + "Intent %s is being overwritten by %s", "test_intent", handler2 + ) + + assert hass.data[intent.DATA_KEY]["test_intent"] == handler2 + + +def test_async_remove(hass: HomeAssistant) -> None: + """Test removing an intent and verifying it is no longer present in the Home Assistant data.""" + handler = MagicMock() + handler.intent_type = "test_intent" + + intent.async_register(hass, handler) + intent.async_remove(hass, "test_intent") + + assert "test_intent" not in hass.data[intent.DATA_KEY] + + +def test_async_remove_no_existing_entry(hass: HomeAssistant) -> None: + """Test the removal of a non-existing intent from Home Assistant's data.""" + handler = MagicMock() + handler.intent_type = "test_intent" + intent.async_register(hass, handler) + + intent.async_remove(hass, "test_intent2") + + assert "test_intent2" not in hass.data[intent.DATA_KEY] + + +def test_async_remove_no_existing(hass: HomeAssistant) -> None: + """Test the removal of an intent where no config exists.""" + + intent.async_remove(hass, "test_intent2") + # simply shouldn't cause an exception + + assert intent.DATA_KEY not in hass.data