Add reload support to intent_script (#93404)
* support live reload of intent_script * add services.yaml * update tesls for full code coverage * Update based on feedback * fix intent_script reload when no intent_script config * Update homeassistant/helpers/intent.py * update tests to handle no_existing better --------- Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
65b62d877d
commit
305fa128fb
7 changed files with 178 additions and 7 deletions
|
@ -5,9 +5,16 @@ import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import CONF_TYPE
|
from homeassistant.const import CONF_TYPE, SERVICE_RELOAD
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, ServiceCall
|
||||||
from homeassistant.helpers import config_validation as cv, intent, script, template
|
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
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -55,10 +62,27 @@ CONFIG_SCHEMA = vol.Schema(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_reload(hass: HomeAssistant, servie_call: ServiceCall) -> None:
|
||||||
"""Set up the intent script component."""
|
"""Handle start Intent Script service call."""
|
||||||
intents = config[DOMAIN]
|
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)
|
template.attach(hass, intents)
|
||||||
|
hass.data[DOMAIN] = intents
|
||||||
|
|
||||||
for intent_type, conf in intents.items():
|
for intent_type, conf in intents.items():
|
||||||
if CONF_ACTION in conf:
|
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))
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
3
homeassistant/components/intent_script/services.yaml
Normal file
3
homeassistant/components/intent_script/services.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
reload:
|
||||||
|
name: Reload
|
||||||
|
description: Reload the intent_script configuration.
|
|
@ -57,6 +57,16 @@ def async_register(hass: HomeAssistant, handler: IntentHandler) -> None:
|
||||||
intents[handler.intent_type] = handler
|
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
|
@bind_hass
|
||||||
async def async_handle(
|
async def async_handle(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
intent_script:
|
||||||
|
NewIntent2:
|
||||||
|
speech:
|
||||||
|
text: Hello World
|
|
@ -1,9 +1,14 @@
|
||||||
"""Test intent_script component."""
|
"""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.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.core import HomeAssistant
|
||||||
from homeassistant.helpers import intent
|
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:
|
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"]["title"] == "Hello Paulus"
|
||||||
assert response.card["simple"]["content"] == "Content for 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
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""Tests for the intent helpers."""
|
"""Tests for the intent helpers."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import voluptuous as vol
|
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.response_type == intent.IntentResponseType.ERROR
|
||||||
assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH
|
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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue