From a1aff5f4a0bf415d0e357c4f7c0197351a334726 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 27 Nov 2023 23:27:51 -0800 Subject: [PATCH] Add websocket `todo/item/subscribe` for subscribing to changes to todo list items (#103952) Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- homeassistant/components/todo/__init__.py | 97 ++++++++++++++++++++- tests/components/caldav/test_todo.py | 71 ++++++++++++++- tests/components/google_tasks/test_todo.py | 76 ++++++++++++++++ tests/components/local_todo/test_todo.py | 61 +++++++++++++ tests/components/shopping_list/test_todo.py | 66 ++++++++++++++ tests/components/todo/test_init.py | 82 +++++++++++++++++ tests/components/todoist/test_todo.py | 60 +++++++++++++ 7 files changed, 510 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 4b76ee5a689..be3c0b57593 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,9 +1,10 @@ """The todo integration.""" +from collections.abc import Callable import dataclasses import datetime import logging -from typing import Any +from typing import Any, final import voluptuous as vol @@ -11,7 +12,13 @@ from homeassistant.components import frontend, websocket_api from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + ServiceCall, + SupportsResponse, + callback, +) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -21,6 +28,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JsonValueType from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature @@ -39,6 +47,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: frontend.async_register_built_in_panel(hass, "todo", "todo", "mdi:clipboard-list") + websocket_api.async_register_command(hass, websocket_handle_subscribe_todo_items) websocket_api.async_register_command(hass, websocket_handle_todo_item_list) websocket_api.async_register_command(hass, websocket_handle_todo_item_move) @@ -131,6 +140,7 @@ class TodoListEntity(Entity): """An entity that represents a To-do list.""" _attr_todo_items: list[TodoItem] | None = None + _update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None @property def state(self) -> int | None: @@ -168,6 +178,89 @@ class TodoListEntity(Entity): """ raise NotImplementedError() + @final + @callback + def async_subscribe_updates( + self, + listener: Callable[[list[JsonValueType] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to To-do list item updates. + + Called by websocket API. + """ + if self._update_listeners is None: + self._update_listeners = [] + self._update_listeners.append(listener) + + @callback + def unsubscribe() -> None: + if self._update_listeners: + self._update_listeners.remove(listener) + + return unsubscribe + + @final + @callback + def async_update_listeners(self) -> None: + """Push updated To-do items to all listeners.""" + if not self._update_listeners: + return + + todo_items: list[JsonValueType] = [ + dataclasses.asdict(item) for item in self.todo_items or () + ] + for listener in self._update_listeners: + listener(todo_items) + + @callback + def _async_write_ha_state(self) -> None: + """Notify to-do item subscribers.""" + super()._async_write_ha_state() + self.async_update_listeners() + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/subscribe", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@websocket_api.async_response +async def websocket_handle_subscribe_todo_items( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to To-do list item updates.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + entity_id: str = msg["entity_id"] + + if not (entity := component.get_entity(entity_id)): + connection.send_error( + msg["id"], + "invalid_entity_id", + f"To-do list entity not found: {entity_id}", + ) + return + + @callback + def todo_item_listener(todo_items: list[JsonValueType] | None) -> None: + """Push updated To-do list items to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "items": todo_items, + }, + ) + ) + + connection.subscriptions[msg["id"]] = entity.async_subscribe_updates( + todo_item_listener + ) + connection.send_result(msg["id"]) + + # Push an initial forecast update + entity.async_update_listeners() + @websocket_api.websocket_command( { diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 31901515e5a..55ae0d564d0 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" @@ -190,7 +191,7 @@ async def test_add_item( assert state assert state.state == "0" - # Simulat return value for the state update after the service call + # Simulate return value for the state update after the service call calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)] await hass.services.async_call( @@ -496,3 +497,71 @@ async def test_remove_item_not_found( target={"entity_id": TEST_ENTITY}, blocking=True, ) + + +async def test_subscribe( + hass: HomeAssistant, + config_entry: MockConfigEntry, + dav_client: Mock, + calendar: Mock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test creating a an item on the list.""" + + item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + calendar.search = MagicMock(return_value=[item]) + + await config_entry.async_setup(hass) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Cheese" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] + + calendar.todo_by_uid = MagicMock(return_value=item) + dav_client.put.return_value.status = 204 + # Reflect update for state refresh after update + calendar.search.return_value = [ + Todo( + dav_client, None, TODO_NEEDS_ACTION.replace("Cheese", "Milk"), calendar, "2" + ) + ] + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "Cheese", + "rename": "Milk", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Milk" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 8b0b49ee109..0b82815b33a 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -796,3 +796,79 @@ async def test_parent_child_ordering( items = await ws_get_items() assert items == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + # refresh after update + { + "items": [ + { + "id": "some-task-id", + "title": "Milk", + "status": "needsAction", + "position": "0000000000000001", + }, + ], + }, + ] + ], +) +async def test_susbcribe( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to item updates.""" + + assert await integration_setup() + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.my_tasks", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Water" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": uid, "rename": "Milk"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index c6246be3dad..5e6aff9cbf3 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -445,3 +445,64 @@ async def test_parse_existing_ics( state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state + + +async def test_susbcribe( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test subscribing to item updates.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": uid, "rename": "milk"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 7c13344ad1d..7722bd8b6da 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -444,3 +444,69 @@ async def test_move_invalid_item( assert not resp.get("success") assert resp.get("error", {}).get("code") == "failed" assert "could not be re-ordered" in resp.get("error", {}).get("message") + + +async def test_subscribe_item( + hass: HomeAssistant, + sl_setup: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + { + "item": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": TEST_ENTITY, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + uid = items[0]["uid"] + assert uid + + # Rename item item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "item": "soda", + "rename": "milk", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "milk" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 907ee695ed1..a65cce27349 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -941,3 +941,85 @@ async def test_remove_completed_items_service_raises( target={"entity_id": "todo.entity1"}, blocking=True, ) + + +async def test_subscribe( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test subscribing to todo updates.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": test_entity.entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + event_message = msg["event"] + assert event_message == { + "items": [ + {"summary": "Item #1", "uid": "1", "status": "needs_action"}, + {"summary": "Item #2", "uid": "2", "status": "completed"}, + ] + } + test_entity._attr_todo_items = [ + *test_entity._attr_todo_items, + TodoItem(summary="Item #3", uid="3", status=TodoItemStatus.NEEDS_ACTION), + ] + + test_entity.async_write_ha_state() + msg = await client.receive_json() + event_message = msg["event"] + assert event_message == { + "items": [ + {"summary": "Item #1", "uid": "1", "status": "needs_action"}, + {"summary": "Item #2", "uid": "2", "status": "completed"}, + {"summary": "Item #3", "uid": "3", "status": "needs_action"}, + ] + } + + test_entity._attr_todo_items = None + test_entity.async_write_ha_state() + msg = await client.receive_json() + event_message = msg["event"] + assert event_message == { + "items": [], + } + + +async def test_subscribe_entity_does_not_exist( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test failure to subscribe to an entity that does not exist.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.unknown", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_entity_id", + "message": "To-do list entity not found: todo.unknown", + } diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index a14f362ea5b..fb6f707be47 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -10,6 +10,8 @@ from homeassistant.helpers.entity_component import async_update_entity from .conftest import PROJECT_ID, make_api_task +from tests.typing import WebSocketGenerator + @pytest.fixture(autouse=True) def platforms() -> list[Platform]: @@ -230,3 +232,61 @@ async def test_remove_todo_item( state = hass.states.get("todo.name") assert state assert state.state == "0" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Cheese", is_completed=False)]] +) +async def test_subscribe( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test for subscribing to state updates.""" + + # Subscribe and get the initial list + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "todo/item/subscribe", + "entity_id": "todo.name", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Cheese" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"] + + # Fake API response when state is refreshed + api.get_tasks.return_value = [ + make_api_task(id="test-id-1", content="Wine", is_completed=False) + ] + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "Cheese", "rename": "Wine"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + # Verify update is published + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + items = msg["event"].get("items") + assert items + assert len(items) == 1 + assert items[0]["summary"] == "Wine" + assert items[0]["status"] == "needs_action" + assert items[0]["uid"]