Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The todo integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Iterable
6 import dataclasses
7 import datetime
8 import logging
9 from typing import Any, final
10 
11 from propcache import cached_property
12 import voluptuous as vol
13 
14 from homeassistant.components import frontend, websocket_api
15 from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED
16 from homeassistant.config_entries import ConfigEntry
17 from homeassistant.const import CONF_ENTITY_ID
18 from homeassistant.core import (
19  CALLBACK_TYPE,
20  HomeAssistant,
21  ServiceCall,
22  SupportsResponse,
23  callback,
24 )
25 from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
26 from homeassistant.helpers import config_validation as cv
27 from homeassistant.helpers.entity import Entity
28 from homeassistant.helpers.entity_component import EntityComponent
29 from homeassistant.helpers.typing import ConfigType
30 from homeassistant.util import dt as dt_util
31 from homeassistant.util.json import JsonValueType
32 
33 from .const import (
34  ATTR_DESCRIPTION,
35  ATTR_DUE,
36  ATTR_DUE_DATE,
37  ATTR_DUE_DATETIME,
38  ATTR_ITEM,
39  ATTR_RENAME,
40  ATTR_STATUS,
41  DATA_COMPONENT,
42  DOMAIN,
43  TodoItemStatus,
44  TodoListEntityFeature,
45  TodoServices,
46 )
47 
48 _LOGGER = logging.getLogger(__name__)
49 
50 ENTITY_ID_FORMAT = DOMAIN + ".{}"
51 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
52 PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
53 SCAN_INTERVAL = datetime.timedelta(seconds=60)
54 
55 
56 @dataclasses.dataclass
58  """A description of To-do item fields and validation requirements."""
59 
60  service_field: str
61  """Field name for service calls."""
62 
63  todo_item_field: str
64  """Field name for TodoItem."""
65 
66  validation: Callable[[Any], Any]
67  """Voluptuous validation function."""
68 
69  required_feature: TodoListEntityFeature
70  """Entity feature that enables this field."""
71 
72 
73 TODO_ITEM_FIELDS = [
75  service_field=ATTR_DUE_DATE,
76  validation=vol.Any(cv.date, None),
77  todo_item_field=ATTR_DUE,
78  required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM,
79  ),
81  service_field=ATTR_DUE_DATETIME,
82  validation=vol.Any(vol.All(cv.datetime, dt_util.as_local), None),
83  todo_item_field=ATTR_DUE,
84  required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM,
85  ),
87  service_field=ATTR_DESCRIPTION,
88  validation=vol.Any(cv.string, None),
89  todo_item_field=ATTR_DESCRIPTION,
90  required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM,
91  ),
92 ]
93 
94 TODO_ITEM_FIELD_SCHEMA = {
95  vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS
96 }
97 TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)]
98 
99 
101  supported_features: int | None, call_data: dict[str, Any]
102 ) -> None:
103  """Validate service call fields against entity supported features."""
104  for desc in TODO_ITEM_FIELDS:
105  if desc.service_field not in call_data:
106  continue
107  if not supported_features or not supported_features & desc.required_feature:
109  translation_domain=DOMAIN,
110  translation_key="update_field_not_supported",
111  translation_placeholders={"service_field": desc.service_field},
112  )
113 
114 
115 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
116  """Set up Todo entities."""
117  component = hass.data[DATA_COMPONENT] = EntityComponent[TodoListEntity](
118  _LOGGER, DOMAIN, hass, SCAN_INTERVAL
119  )
120 
121  frontend.async_register_built_in_panel(hass, "todo", "todo", "mdi:clipboard-list")
122 
123  websocket_api.async_register_command(hass, websocket_handle_subscribe_todo_items)
124  websocket_api.async_register_command(hass, websocket_handle_todo_item_list)
125  websocket_api.async_register_command(hass, websocket_handle_todo_item_move)
126 
127  component.async_register_entity_service(
128  TodoServices.ADD_ITEM,
129  vol.All(
130  cv.make_entity_service_schema(
131  {
132  vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)),
133  **TODO_ITEM_FIELD_SCHEMA,
134  }
135  ),
136  *TODO_ITEM_FIELD_VALIDATIONS,
137  ),
138  _async_add_todo_item,
139  required_features=[TodoListEntityFeature.CREATE_TODO_ITEM],
140  )
141  component.async_register_entity_service(
142  TodoServices.UPDATE_ITEM,
143  vol.All(
144  cv.make_entity_service_schema(
145  {
146  vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)),
147  vol.Optional(ATTR_RENAME): vol.All(cv.string, vol.Length(min=1)),
148  vol.Optional(ATTR_STATUS): vol.In(
149  {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED},
150  ),
151  **TODO_ITEM_FIELD_SCHEMA,
152  }
153  ),
154  *TODO_ITEM_FIELD_VALIDATIONS,
155  cv.has_at_least_one_key(
156  ATTR_RENAME,
157  ATTR_STATUS,
158  *[desc.service_field for desc in TODO_ITEM_FIELDS],
159  ),
160  ),
161  _async_update_todo_item,
162  required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM],
163  )
164  component.async_register_entity_service(
165  TodoServices.REMOVE_ITEM,
166  cv.make_entity_service_schema(
167  {
168  vol.Required(ATTR_ITEM): vol.All(cv.ensure_list, [cv.string]),
169  }
170  ),
171  _async_remove_todo_items,
172  required_features=[TodoListEntityFeature.DELETE_TODO_ITEM],
173  )
174  component.async_register_entity_service(
175  TodoServices.GET_ITEMS,
176  cv.make_entity_service_schema(
177  {
178  vol.Optional(ATTR_STATUS): vol.All(
179  cv.ensure_list,
180  [vol.In({TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED})],
181  ),
182  }
183  ),
184  _async_get_todo_items,
185  supports_response=SupportsResponse.ONLY,
186  )
187  component.async_register_entity_service(
188  TodoServices.REMOVE_COMPLETED_ITEMS,
189  None,
190  _async_remove_completed_items,
191  required_features=[TodoListEntityFeature.DELETE_TODO_ITEM],
192  )
193 
194  await component.async_setup(config)
195  return True
196 
197 
198 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
199  """Set up a config entry."""
200  return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
201 
202 
203 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
204  """Unload a config entry."""
205  return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
206 
207 
208 @dataclasses.dataclass
209 class TodoItem:
210  """A To-do item in a To-do list."""
211 
212  summary: str | None = None
213  """The summary that represents the item."""
214 
215  uid: str | None = None
216  """A unique identifier for the To-do item."""
217 
218  status: TodoItemStatus | None = None
219  """A status or confirmation of the To-do item."""
220 
221  due: datetime.date | datetime.datetime | None = None
222  """The date and time that a to-do is expected to be completed.
223 
224  This field may be a date or datetime depending whether the entity feature
225  DUE_DATE or DUE_DATETIME are set.
226  """
227 
228  description: str | None = None
229  """A more complete description of than that provided by the summary.
230 
231  This field may be set when TodoListEntityFeature.DESCRIPTION is supported by
232  the entity.
233  """
234 
235 
236 CACHED_PROPERTIES_WITH_ATTR_ = {
237  "todo_items",
238 }
239 
240 
241 class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
242  """An entity that represents a To-do list."""
243 
244  _attr_todo_items: list[TodoItem] | None = None
245  _update_listeners: list[Callable[[list[JsonValueType] | None], None]] | None = None
246 
247  @property
248  def state(self) -> int | None:
249  """Return the entity state as the count of incomplete items."""
250  items = self.todo_itemstodo_items
251  if items is None:
252  return None
253  return sum([item.status == TodoItemStatus.NEEDS_ACTION for item in items])
254 
255  @cached_property
256  def todo_items(self) -> list[TodoItem] | None:
257  """Return the To-do items in the To-do list."""
258  return self._attr_todo_items
259 
260  async def async_create_todo_item(self, item: TodoItem) -> None:
261  """Add an item to the To-do list."""
262  raise NotImplementedError
263 
264  async def async_update_todo_item(self, item: TodoItem) -> None:
265  """Update an item in the To-do list."""
266  raise NotImplementedError
267 
268  async def async_delete_todo_items(self, uids: list[str]) -> None:
269  """Delete an item in the To-do list."""
270  raise NotImplementedError
271 
273  self, uid: str, previous_uid: str | None = None
274  ) -> None:
275  """Move an item in the To-do list.
276 
277  The To-do item with the specified `uid` should be moved to the position
278  in the list after the specified by `previous_uid` or `None` for the first
279  position in the To-do list.
280  """
281  raise NotImplementedError
282 
283  @final
284  @callback
286  self,
287  listener: Callable[[list[JsonValueType] | None], None],
288  ) -> CALLBACK_TYPE:
289  """Subscribe to To-do list item updates.
290 
291  Called by websocket API.
292  """
293  if self._update_listeners_update_listeners is None:
294  self._update_listeners_update_listeners = []
295  self._update_listeners_update_listeners.append(listener)
296 
297  @callback
298  def unsubscribe() -> None:
299  if self._update_listeners_update_listeners:
300  self._update_listeners_update_listeners.remove(listener)
301 
302  return unsubscribe
303 
304  @final
305  @callback
306  def async_update_listeners(self) -> None:
307  """Push updated To-do items to all listeners."""
308  if not self._update_listeners_update_listeners:
309  return
310 
311  todo_items: list[JsonValueType] = [
312  dataclasses.asdict(item) for item in self.todo_itemstodo_items or ()
313  ]
314  for listener in self._update_listeners_update_listeners:
315  listener(todo_items)
316 
317  @callback
318  def _async_write_ha_state(self) -> None:
319  """Notify to-do item subscribers."""
320  super()._async_write_ha_state()
321  self.async_update_listenersasync_update_listeners()
322 
323 
324 @websocket_api.websocket_command( { vol.Required("type"): "todo/item/subscribe",
325  vol.Required("entity_id"): cv.entity_domain(DOMAIN),
326  }
327 )
328 @websocket_api.async_response
330  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
331 ) -> None:
332  """Subscribe to To-do list item updates."""
333  entity_id: str = msg["entity_id"]
334 
335  if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
336  connection.send_error(
337  msg["id"],
338  "invalid_entity_id",
339  f"To-do list entity not found: {entity_id}",
340  )
341  return
342 
343  @callback
344  def todo_item_listener(todo_items: list[JsonValueType] | None) -> None:
345  """Push updated To-do list items to websocket."""
346  connection.send_message(
347  websocket_api.event_message(
348  msg["id"],
349  {
350  "items": todo_items,
351  },
352  )
353  )
354 
355  connection.subscriptions[msg["id"]] = entity.async_subscribe_updates(
356  todo_item_listener
357  )
358  connection.send_result(msg["id"])
359 
360  # Push an initial forecast update
361  entity.async_update_listeners()
362 
363 
364 def _api_items_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]:
365  """Convert CalendarEvent dataclass items to dictionary of attributes."""
366  result: dict[str, str] = {}
367  for name, value in obj:
368  if value is None:
369  continue
370  if isinstance(value, (datetime.date, datetime.datetime)):
371  result[name] = value.isoformat()
372  else:
373  result[name] = str(value)
374  return result
375 
376 
377 @websocket_api.websocket_command( { vol.Required("type"): "todo/item/list",
378  vol.Required("entity_id"): cv.entity_id,
379  }
380 )
381 @websocket_api.async_response
383  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
384 ) -> None:
385  """Handle the list of To-do items in a To-do- list."""
386  if (
387  not (entity_id := msg[CONF_ENTITY_ID])
388  or not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id))
389  or not isinstance(entity, TodoListEntity)
390  ):
391  connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
392  return
393 
394  items: list[TodoItem] = entity.todo_items or []
395  connection.send_message(
396  websocket_api.result_message(
397  msg["id"],
398  {
399  "items": [
400  dataclasses.asdict(item, dict_factory=_api_items_factory)
401  for item in items
402  ]
403  },
404  )
405  )
406 
407 
408 @websocket_api.websocket_command( { vol.Required("type"): "todo/item/move",
409  vol.Required("entity_id"): cv.entity_id,
410  vol.Required("uid"): cv.string,
411  vol.Optional("previous_uid"): cv.string,
412  }
413 )
414 @websocket_api.async_response
416  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
417 ) -> None:
418  """Handle move of a To-do item within a To-do list."""
419  if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
420  connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
421  return
422 
423  if (
424  not entity.supported_features
425  or not entity.supported_features & TodoListEntityFeature.MOVE_TODO_ITEM
426  ):
427  connection.send_message(
428  websocket_api.error_message(
429  msg["id"],
430  ERR_NOT_SUPPORTED,
431  "To-do list does not support To-do item reordering",
432  )
433  )
434  return
435  try:
436  await entity.async_move_todo_item(
437  uid=msg["uid"], previous_uid=msg.get("previous_uid")
438  )
439  except HomeAssistantError as ex:
440  connection.send_error(msg["id"], "failed", str(ex))
441  else:
442  connection.send_result(msg["id"])
443 
444 
446  value: str, items: list[TodoItem] | None
447 ) -> TodoItem | None:
448  """Find a To-do List item by uid or summary name."""
449  for item in items or ():
450  if value in (item.uid, item.summary):
451  return item
452  return None
453 
454 
455 async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None:
456  """Add an item to the To-do list."""
457  _validate_supported_features(entity.supported_features, call.data)
458  await entity.async_create_todo_item(
459  item=TodoItem(
460  summary=call.data["item"],
461  status=TodoItemStatus.NEEDS_ACTION,
462  **{
463  desc.todo_item_field: call.data[desc.service_field]
464  for desc in TODO_ITEM_FIELDS
465  if desc.service_field in call.data
466  },
467  )
468  )
469 
470 
471 async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> None:
472  """Update an item in the To-do list."""
473  item = call.data["item"]
474  found = _find_by_uid_or_summary(item, entity.todo_items)
475  if not found:
477  translation_domain=DOMAIN,
478  translation_key="item_not_found",
479  translation_placeholders={"item": item},
480  )
481 
482  _validate_supported_features(entity.supported_features, call.data)
483 
484  # Perform a partial update on the existing entity based on the fields
485  # present in the update. This allows explicitly clearing any of the
486  # extended fields present and set to None.
487  updated_data = dataclasses.asdict(found)
488  if summary := call.data.get("rename"):
489  updated_data["summary"] = summary
490  if status := call.data.get("status"):
491  updated_data["status"] = status
492  updated_data.update(
493  {
494  desc.todo_item_field: call.data[desc.service_field]
495  for desc in TODO_ITEM_FIELDS
496  if desc.service_field in call.data
497  }
498  )
499  await entity.async_update_todo_item(item=TodoItem(**updated_data))
500 
501 
502 async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None:
503  """Remove an item in the To-do list."""
504  uids = []
505  for item in call.data.get("item", []):
506  found = _find_by_uid_or_summary(item, entity.todo_items)
507  if not found or not found.uid:
509  translation_domain=DOMAIN,
510  translation_key="item_not_found",
511  translation_placeholders={"item": item},
512  )
513  uids.append(found.uid)
514  await entity.async_delete_todo_items(uids=uids)
515 
516 
517 async def _async_get_todo_items(
518  entity: TodoListEntity, call: ServiceCall
519 ) -> dict[str, Any]:
520  """Return items in the To-do list."""
521  return {
522  "items": [
523  dataclasses.asdict(item, dict_factory=_api_items_factory)
524  for item in entity.todo_items or ()
525  if not (statuses := call.data.get("status")) or item.status in statuses
526  ]
527  }
528 
529 
530 async def _async_remove_completed_items(entity: TodoListEntity, _: ServiceCall) -> None:
531  """Remove all completed items from the To-do list."""
532  uids = [
533  item.uid
534  for item in entity.todo_items or ()
535  if item.status == TodoItemStatus.COMPLETED and item.uid
536  ]
537  if uids:
538  await entity.async_delete_todo_items(uids=uids)
539 
None async_move_todo_item(self, str uid, str|None previous_uid=None)
Definition: __init__.py:274
None async_create_todo_item(self, TodoItem item)
Definition: __init__.py:260
None async_update_todo_item(self, TodoItem item)
Definition: __init__.py:264
None async_delete_todo_items(self, list[str] uids)
Definition: __init__.py:268
CALLBACK_TYPE async_subscribe_updates(self, Callable[[list[JsonValueType]|None], None] listener)
Definition: __init__.py:288
list[TodoItem]|None todo_items(self)
Definition: __init__.py:256
bool remove(self, _T matcher)
Definition: match.py:214
CalendarEntity get_entity(HomeAssistant hass, str entity_id)
Definition: trigger.py:96
None _validate_supported_features(int|None supported_features, dict[str, Any] call_data)
Definition: __init__.py:102
None websocket_handle_todo_item_move(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:423
dict[str, str] _api_items_factory(Iterable[tuple[str, Any]] obj)
Definition: __init__.py:366
None websocket_handle_subscribe_todo_items(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:333
TodoItem|None _find_by_uid_or_summary(str value, list[TodoItem]|None items)
Definition: __init__.py:453
None _async_remove_completed_items(TodoListEntity entity, ServiceCall _)
Definition: __init__.py:536
None _async_remove_todo_items(TodoListEntity entity, ServiceCall call)
Definition: __init__.py:508
None _async_add_todo_item(TodoListEntity entity, ServiceCall call)
Definition: __init__.py:461
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:198
dict[str, Any] _async_get_todo_items(TodoListEntity entity, ServiceCall call)
Definition: __init__.py:525
None websocket_handle_todo_item_list(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: __init__.py:388
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:203
None _async_update_todo_item(TodoListEntity entity, ServiceCall call)
Definition: __init__.py:477
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:115