From 9b604b4a1c9a092d8590d54d349165f6f417c098 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Oct 2023 14:36:44 -0500 Subject: [PATCH 1/4] Add HassListAddItem intent --- homeassistant/components/todo/intent.py | 78 +++++++++++++++++++++++++ tests/components/todo/test_init.py | 69 ++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 homeassistant/components/todo/intent.py diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py new file mode 100644 index 00000000000..3afb61d2977 --- /dev/null +++ b/homeassistant/components/todo/intent.py @@ -0,0 +1,78 @@ +"""Intents for the todo integration.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent + +from . import DOMAIN, TodoItem, TodoListEntity + +INTENT_ADD_ITEM = "HassListAddItem" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the todo intents.""" + intent.async_register(hass, AddItemIntent()) + + +class AddItemIntent(intent.IntentHandler): + """Handle AddItem intents.""" + + intent_type = INTENT_ADD_ITEM + slot_schema = {"item": cv.string, vol.Optional("list"): cv.string} + + async def async_handle(self, intent_obj: intent.Intent): + """Handle the intent.""" + hass = intent_obj.hass + + slots = self.async_validate_slots(intent_obj.slots) + item = slots["item"]["value"] + + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + list_entities: list[TodoListEntity] = list(component.entities) + if not list_entities: + raise intent.IntentHandleError("No list entities") + + target_list: TodoListEntity | None = None + + if "list" in slots: + # Add to a list by name + list_name = slots["list"]["value"] + + # Try literal name match first + for maybe_list in list_entities: + if not isinstance(maybe_list.name, str): + continue + + if list_name == maybe_list.name: + target_list = maybe_list + break + + if target_list is None: + list_name_norm = list_name.strip().lower() + for maybe_list in list_entities: + if not isinstance(maybe_list.name, str): + continue + + maybe_name_norm = maybe_list.name.strip().lower() + if list_name_norm == maybe_name_norm: + target_list = maybe_list + break + + if target_list is None: + raise intent.IntentHandleError(f"No list named {list_name}") + else: + # Add to the first list + target_list = list_entities[0] + + assert target_list is not None + + # Add to list + await target_list.async_create_todo_item(TodoItem(item)) + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + return response diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 833a4ea266b..22b7385184f 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -13,11 +13,13 @@ from homeassistant.components.todo import ( TodoItemStatus, TodoListEntity, TodoListEntityFeature, + intent as todo_intent, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import ( @@ -37,6 +39,18 @@ class MockFlow(ConfigFlow): """Test flow.""" +class MockTodoListEntity(TodoListEntity): + """Test todo list entity.""" + + def __init__(self) -> None: + """Initialize entity.""" + self.items: list[TodoItem] = [] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + self.items.append(item) + + @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: """Mock config flow.""" @@ -728,3 +742,58 @@ async def test_move_item_unsupported( resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == "not_supported" + + +async def test_add_item_intent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test adding items to lists using an intent.""" + await todo_intent.async_setup_intents(hass) + + entity1 = MockTodoListEntity() + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + entity2 = MockTodoListEntity() + entity2._attr_name = "List 2" + entity2.entity_id = "todo.list_2" + + await create_mock_platform(hass, [entity1, entity2]) + + # Add to first list + response = await intent.async_handle( + hass, "test", "HassListAddItem", {"item": {"value": "beer"}} + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 1 + assert len(entity2.items) == 0 + assert entity1.items[0].summary == "beer" + entity1.items.clear() + + # Add to list by name + response = await intent.async_handle( + hass, + "test", + "HassListAddItem", + {"item": {"value": "cheese"}, "list": {"value": "List 2"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 1 + assert entity2.items[0].summary == "cheese" + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + "HassListAddItem", + {"item": {"value": "wine"}, "list": {"value": "lIST 2"}}, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 2 + assert entity2.items[1].summary == "wine" From 800f816e9fadb01718e2505cf5be9a3f4a1e7b53 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Oct 2023 15:42:33 -0500 Subject: [PATCH 2/4] Migrate HassShoppingListAddItem to todo --- .../components/shopping_list/intent.py | 24 ++------------- homeassistant/components/todo/intent.py | 29 ++++++++----------- tests/components/shopping_list/test_intent.py | 4 +++ tests/components/todo/test_init.py | 6 ++-- 4 files changed, 22 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index d6a29eb73f3..17e24f2bfae 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -1,38 +1,20 @@ """Intents for the Shopping List integration.""" from __future__ import annotations +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv -from . import DOMAIN, EVENT_SHOPPING_LIST_UPDATED +from . import DOMAIN -INTENT_ADD_ITEM = "HassShoppingListAddItem" INTENT_LAST_ITEMS = "HassShoppingListLastItems" -async def async_setup_intents(hass): +async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the Shopping List intents.""" - intent.async_register(hass, AddItemIntent()) intent.async_register(hass, ListTopItemsIntent()) -class AddItemIntent(intent.IntentHandler): - """Handle AddItem intents.""" - - intent_type = INTENT_ADD_ITEM - slot_schema = {"item": cv.string} - - async def async_handle(self, intent_obj: intent.Intent): - """Handle the intent.""" - slots = self.async_validate_slots(intent_obj.slots) - item = slots["item"]["value"] - await intent_obj.hass.data[DOMAIN].async_add(item) - - response = intent_obj.create_response() - intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) - return response - - class ListTopItemsIntent(intent.IntentHandler): """Handle AddItem intents.""" diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index 3afb61d2977..fdbf59d55fe 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -3,6 +3,7 @@ from __future__ import annotations import voluptuous as vol +from homeassistant.components.shopping_list import EVENT_SHOPPING_LIST_UPDATED from homeassistant.core import HomeAssistant from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv @@ -10,7 +11,7 @@ from homeassistant.helpers.entity_component import EntityComponent from . import DOMAIN, TodoItem, TodoListEntity -INTENT_ADD_ITEM = "HassListAddItem" +INTENT_ADD_ITEM = "HassShoppingListAddItem" async def async_setup_intents(hass: HomeAssistant) -> None: @@ -42,26 +43,19 @@ class AddItemIntent(intent.IntentHandler): # Add to a list by name list_name = slots["list"]["value"] - # Try literal name match first - for maybe_list in list_entities: - if not isinstance(maybe_list.name, str): - continue - - if list_name == maybe_list.name: - target_list = maybe_list - break - - if target_list is None: - list_name_norm = list_name.strip().lower() + # Find matching list + matching_states = intent.async_match_states( + hass, name=list_name, domains=[DOMAIN] + ) + for list_state in matching_states: for maybe_list in list_entities: - if not isinstance(maybe_list.name, str): - continue - - maybe_name_norm = maybe_list.name.strip().lower() - if list_name_norm == maybe_name_norm: + if maybe_list.entity_id == list_state.entity_id: target_list = maybe_list break + if target_list is not None: + break + if target_list is None: raise intent.IntentHandleError(f"No list named {list_name}") else: @@ -75,4 +69,5 @@ class AddItemIntent(intent.IntentHandler): response = intent_obj.create_response() response.response_type = intent.IntentResponseType.ACTION_DONE + intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) return response diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index 50c698def5d..efbae06d7a1 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -1,10 +1,14 @@ """Test Shopping List intents.""" +from homeassistant.components.todo import intent as todo_intent from homeassistant.core import HomeAssistant from homeassistant.helpers import intent async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None: """Test recent items.""" + # HassShoppingListAddItem is in todo now + await todo_intent.async_setup_intents(hass) + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 22b7385184f..368a206552f 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -763,7 +763,7 @@ async def test_add_item_intent( # Add to first list response = await intent.async_handle( - hass, "test", "HassListAddItem", {"item": {"value": "beer"}} + hass, "test", todo_intent.INTENT_ADD_ITEM, {"item": {"value": "beer"}} ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -776,7 +776,7 @@ async def test_add_item_intent( response = await intent.async_handle( hass, "test", - "HassListAddItem", + todo_intent.INTENT_ADD_ITEM, {"item": {"value": "cheese"}, "list": {"value": "List 2"}}, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -789,7 +789,7 @@ async def test_add_item_intent( response = await intent.async_handle( hass, "test", - "HassListAddItem", + todo_intent.INTENT_ADD_ITEM, {"item": {"value": "wine"}, "list": {"value": "lIST 2"}}, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE From 4ba9358d5cf185515e0c12d175d04b3d8cedfcbb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 25 Oct 2023 10:46:08 -0500 Subject: [PATCH 3/4] Shopping list depends on intent/todo --- homeassistant/components/shopping_list/manifest.json | 2 +- tests/components/shopping_list/conftest.py | 1 - tests/components/shopping_list/test_intent.py | 4 ---- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/shopping_list/manifest.json b/homeassistant/components/shopping_list/manifest.json index 12dfd238fce..da3f7231afd 100644 --- a/homeassistant/components/shopping_list/manifest.json +++ b/homeassistant/components/shopping_list/manifest.json @@ -3,7 +3,7 @@ "name": "Shopping List", "codeowners": [], "config_flow": true, - "dependencies": ["http"], + "dependencies": ["http", "intent", "todo"], "documentation": "https://www.home-assistant.io/integrations/shopping_list", "iot_class": "local_push", "quality_scale": "internal" diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index aec55362d0b..ee6a73f0d65 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -27,7 +27,6 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry): """Set up the shopping list.""" - mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/shopping_list/test_intent.py b/tests/components/shopping_list/test_intent.py index efbae06d7a1..50c698def5d 100644 --- a/tests/components/shopping_list/test_intent.py +++ b/tests/components/shopping_list/test_intent.py @@ -1,14 +1,10 @@ """Test Shopping List intents.""" -from homeassistant.components.todo import intent as todo_intent from homeassistant.core import HomeAssistant from homeassistant.helpers import intent async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None: """Test recent items.""" - # HassShoppingListAddItem is in todo now - await todo_intent.async_setup_intents(hass) - await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) From a1c23b0235bcf736189462f12cbdac4283e0ddb9 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 25 Oct 2023 10:59:53 -0500 Subject: [PATCH 4/4] Fix shopping list tests --- tests/components/shopping_list/conftest.py | 2 +- tests/components/shopping_list/test_todo.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index ee6a73f0d65..dd81b4ba734 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -21,7 +21,7 @@ def mock_shopping_list_io(): @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Config Entry fixture.""" - return MockConfigEntry(domain="shopping_list") + return MockConfigEntry(domain="shopping_list", entry_id="1234") @pytest.fixture diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 15f1e50bdb9..feba7c124f8 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -11,7 +11,8 @@ from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator -TEST_ENTITY = "todo.shopping_list" +# NOTE: This depends on the config entry_id in sl_setup +TEST_ENTITY = "todo.shopping_list_1234" @pytest.fixture