Add websocket todo/item/subscribe for subscribing to changes to todo list items (#103952)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-11-27 23:27:51 -08:00 committed by GitHub
parent e048ad5a62
commit a1aff5f4a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 510 additions and 3 deletions

View File

@ -1,9 +1,10 @@
"""The todo integration.""" """The todo integration."""
from collections.abc import Callable
import dataclasses import dataclasses
import datetime import datetime
import logging import logging
from typing import Any from typing import Any, final
import voluptuous as vol 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.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID 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 from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import ( # noqa: F401 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 import Entity
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.json import JsonValueType
from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature 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") 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_list)
websocket_api.async_register_command(hass, websocket_handle_todo_item_move) 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.""" """An entity that represents a To-do list."""
_attr_todo_items: list[TodoItem] | None = None _attr_todo_items: list[TodoItem] | None = None
_update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None
@property @property
def state(self) -> int | None: def state(self) -> int | None:
@ -168,6 +178,89 @@ class TodoListEntity(Entity):
""" """
raise NotImplementedError() 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( @websocket_api.websocket_command(
{ {

View File

@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
CALENDAR_NAME = "My Tasks" CALENDAR_NAME = "My Tasks"
ENTITY_NAME = "My tasks" ENTITY_NAME = "My tasks"
@ -190,7 +191,7 @@ async def test_add_item(
assert state assert state
assert state.state == "0" 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)] calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)]
await hass.services.async_call( await hass.services.async_call(
@ -496,3 +497,71 @@ async def test_remove_item_not_found(
target={"entity_id": TEST_ENTITY}, target={"entity_id": TEST_ENTITY},
blocking=True, 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"]

View File

@ -796,3 +796,79 @@ async def test_parent_child_ordering(
items = await ws_get_items() items = await ws_get_items()
assert items == snapshot 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]

View File

@ -445,3 +445,64 @@ async def test_parse_existing_ics(
state = hass.states.get(TEST_ENTITY) state = hass.states.get(TEST_ENTITY)
assert state assert state
assert state.state == expected_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]

View File

@ -444,3 +444,69 @@ async def test_move_invalid_item(
assert not resp.get("success") assert not resp.get("success")
assert resp.get("error", {}).get("code") == "failed" assert resp.get("error", {}).get("code") == "failed"
assert "could not be re-ordered" in resp.get("error", {}).get("message") 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]

View File

@ -941,3 +941,85 @@ async def test_remove_completed_items_service_raises(
target={"entity_id": "todo.entity1"}, target={"entity_id": "todo.entity1"},
blocking=True, 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",
}

View File

@ -10,6 +10,8 @@ from homeassistant.helpers.entity_component import async_update_entity
from .conftest import PROJECT_ID, make_api_task from .conftest import PROJECT_ID, make_api_task
from tests.typing import WebSocketGenerator
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def platforms() -> list[Platform]: def platforms() -> list[Platform]:
@ -230,3 +232,61 @@ async def test_remove_todo_item(
state = hass.states.get("todo.name") state = hass.states.get("todo.name")
assert state assert state
assert state.state == "0" 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"]