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
parent
e048ad5a62
commit
a1aff5f4a0
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Reference in New Issue