176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
"""To-do platform for the Maintenance Supporter integration.
|
|
|
|
A single global ``todo`` list entity aggregates every active maintenance task
|
|
across all objects, so maintenance shows up in Home Assistant's native **To-do**
|
|
card and is reachable through **Assist/voice** ("what maintenance is due?",
|
|
"mark the filter change done").
|
|
|
|
Each task becomes one to-do item whose status mirrors its due state: a task
|
|
that is due-soon / overdue / triggered is ``needs_action`` (actionable), an
|
|
up-to-date task is ``completed``. Checking a ``needs_action`` item off routes to
|
|
the same ``complete_maintenance`` coordinator method the panel, buttons, and
|
|
notification actions use — one action layer, no duplicated logic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import date
|
|
from typing import TYPE_CHECKING
|
|
|
|
from homeassistant.components.todo import TodoItem, TodoListEntity
|
|
from homeassistant.components.todo.const import (
|
|
TodoItemStatus,
|
|
TodoListEntityFeature,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .const import (
|
|
CONF_OBJECT,
|
|
CONF_TASK_ENABLED,
|
|
CONF_TASKS,
|
|
DOMAIN,
|
|
GLOBAL_UNIQUE_ID,
|
|
MaintenanceStatus,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from . import MaintenanceSupporterConfigEntry
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PARALLEL_UPDATES = 0
|
|
|
|
# Statuses that make a task "actionable" in the to-do list (needs_action).
|
|
_ACTION_STATUSES = frozenset(
|
|
{
|
|
MaintenanceStatus.DUE_SOON,
|
|
MaintenanceStatus.OVERDUE,
|
|
MaintenanceStatus.TRIGGERED,
|
|
}
|
|
)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: MaintenanceSupporterConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the single global maintenance to-do list.
|
|
|
|
Mirrors the calendar platform: the list lives on the global entry and
|
|
aggregates every object's coordinator. Object entries register their
|
|
coordinator so the list repaints when a task's status changes.
|
|
"""
|
|
if entry.unique_id != GLOBAL_UNIQUE_ID:
|
|
runtime_data = entry.runtime_data
|
|
if runtime_data and runtime_data.coordinator:
|
|
todo = hass.data.get(DOMAIN, {}).get("_todo_entity")
|
|
if todo:
|
|
runtime_data.coordinator.register_todo_entity(todo)
|
|
return
|
|
|
|
todo = MaintenanceTodoList(hass)
|
|
hass.data.setdefault(DOMAIN, {})["_todo_entity"] = todo
|
|
async_add_entities([todo])
|
|
|
|
for other_entry in hass.config_entries.async_entries(DOMAIN):
|
|
if other_entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
other_data = getattr(other_entry, "runtime_data", None)
|
|
if other_data and getattr(other_data, "coordinator", None):
|
|
other_data.coordinator.register_todo_entity(todo)
|
|
|
|
_LOGGER.debug("Maintenance to-do list entity created")
|
|
|
|
|
|
class MaintenanceTodoList(TodoListEntity):
|
|
"""A single list of every active maintenance task, keyed by due state."""
|
|
|
|
_attr_name = "Maintenance"
|
|
_attr_unique_id = "maintenance_supporter_todo"
|
|
_attr_translation_key = "maintenance_todo"
|
|
_attr_should_poll = False
|
|
_attr_supported_features = TodoListEntityFeature.UPDATE_TODO_ITEM
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize the to-do list."""
|
|
self._hass = hass
|
|
|
|
def refresh(self) -> None:
|
|
"""Repaint the list (called by coordinators on update)."""
|
|
if self.hass is not None:
|
|
self.async_write_ha_state()
|
|
|
|
@property
|
|
def todo_items(self) -> list[TodoItem]:
|
|
"""Build one item per active task across all objects."""
|
|
items: list[TodoItem] = []
|
|
for entry in self._hass.config_entries.async_entries(DOMAIN):
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
obj_data = entry.data.get(CONF_OBJECT, {})
|
|
if obj_data.get("archived_at") is not None:
|
|
continue
|
|
obj_name = obj_data.get("name", "Unknown")
|
|
|
|
runtime_data = getattr(entry, "runtime_data", None)
|
|
coordinator = getattr(runtime_data, "coordinator", None)
|
|
live = coordinator.data.get(CONF_TASKS, {}) if coordinator and coordinator.data else {}
|
|
|
|
for task_id, task_cfg in entry.data.get(CONF_TASKS, {}).items():
|
|
if not task_cfg.get(CONF_TASK_ENABLED, True):
|
|
continue
|
|
if task_cfg.get("archived_at") is not None:
|
|
continue
|
|
task_live = live.get(task_id, {})
|
|
status = task_live.get("_status", MaintenanceStatus.OK)
|
|
# Archived is retired; paused (v2.20, N3) is frozen — neither
|
|
# belongs on the actionable To-do list.
|
|
if status in (MaintenanceStatus.ARCHIVED, MaintenanceStatus.PAUSED):
|
|
continue
|
|
due_iso = task_live.get("_next_due")
|
|
due: date | None = None
|
|
if isinstance(due_iso, str):
|
|
try:
|
|
due = date.fromisoformat(due_iso)
|
|
except ValueError:
|
|
due = None
|
|
items.append(
|
|
TodoItem(
|
|
uid=f"{entry.entry_id}:{task_id}",
|
|
summary=f"{obj_name}: {task_cfg.get('name', '')}",
|
|
status=(TodoItemStatus.NEEDS_ACTION if status in _ACTION_STATUSES else TodoItemStatus.COMPLETED),
|
|
due=due,
|
|
)
|
|
)
|
|
return items
|
|
|
|
async def async_update_todo_item(self, item: TodoItem) -> None:
|
|
"""Handle a checkbox toggle: completing an item completes the task.
|
|
|
|
Un-checking (back to needs_action) is a no-op — a maintenance task can't
|
|
be "un-completed"; its next cycle is driven by the schedule.
|
|
"""
|
|
if item.status != TodoItemStatus.COMPLETED or not item.uid:
|
|
return
|
|
entry_id, _, task_id = item.uid.partition(":")
|
|
entry = self._hass.config_entries.async_get_entry(entry_id)
|
|
if entry is None:
|
|
return
|
|
coordinator = getattr(getattr(entry, "runtime_data", None), "coordinator", None)
|
|
if coordinator is None:
|
|
return
|
|
# Respect a task's completion window — silently no-op (the item stays
|
|
# needs_action) rather than completing an item that's not yet due.
|
|
from .models.maintenance_task import MaintenanceTask
|
|
|
|
td = coordinator._get_merged_tasks_data().get(task_id)
|
|
if td and not MaintenanceTask.from_dict(td).can_complete_now:
|
|
return
|
|
await coordinator.complete_maintenance(
|
|
task_id, notes="Completed from the To-do list", unattended=True
|
|
)
|
|
self.async_write_ha_state()
|