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>
pull/94698/head
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."""
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(
{

View File

@ -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"]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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",
}

View File

@ -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"]