225 lines
9.5 KiB
Python
225 lines
9.5 KiB
Python
"""Single source of truth for cross-entry status aggregation.
|
|
|
|
Both the ``maintenance_supporter/statistics`` WebSocket endpoint (which feeds
|
|
the panel KPI chips and the Lovelace card header) and the global summary
|
|
sensors compute their counts here, so the numbers can never diverge.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator, Mapping
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from ..const import (
|
|
CONF_OBJECT,
|
|
CONF_TASKS,
|
|
DEFAULT_TASK_PRIORITY,
|
|
DOMAIN,
|
|
GLOBAL_UNIQUE_ID,
|
|
MaintenanceStatus,
|
|
TaskPriority,
|
|
slugify_object_name,
|
|
task_unique_id,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from .. import MaintenanceSupporterData
|
|
from ..storage import MaintenanceStore
|
|
|
|
# The status buckets we count. `ok` is included so the dashboard strategy
|
|
# headline and a future summary sensor have a single source for it too.
|
|
_COUNTED_STATUSES = (
|
|
MaintenanceStatus.OVERDUE,
|
|
MaintenanceStatus.DUE_SOON,
|
|
MaintenanceStatus.TRIGGERED,
|
|
MaintenanceStatus.OK,
|
|
)
|
|
|
|
# Sort rank of a task priority — high-priority work first (#134). ONE table
|
|
# for the notification manager's daily-limit fairness and the Assist
|
|
# "what is due?" ordering, which kept its own inline copy (DRY audit
|
|
# 2026-09-26 B). An unknown value ranks like "normal".
|
|
PRIORITY_RANK: dict[str, int] = {TaskPriority.HIGH: 0, TaskPriority.NORMAL: 1, TaskPriority.LOW: 2}
|
|
|
|
|
|
def priority_rank(priority: Any) -> int:
|
|
"""The :data:`PRIORITY_RANK` of a stored priority value (unset = normal)."""
|
|
return PRIORITY_RANK.get(str(priority or DEFAULT_TASK_PRIORITY), PRIORITY_RANK[TaskPriority.NORMAL])
|
|
|
|
|
|
def is_object_entry(entry: ConfigEntry | None) -> bool:
|
|
"""True iff *entry* is one of OUR object entries (not None, not another
|
|
domain's, not the global settings entry).
|
|
|
|
The three-part guard was hand-typed at eight sites (WS resolver, the two
|
|
adopt handlers, the service-facing task helpers, the foreign-part
|
|
resolver, …) — one predicate so a future site can't forget a leg.
|
|
"""
|
|
return entry is not None and entry.domain == DOMAIN and entry.unique_id != GLOBAL_UNIQUE_ID
|
|
|
|
|
|
def object_name(entry: ConfigEntry) -> str:
|
|
"""The object's display name, falling back to the entry title.
|
|
|
|
One rule for the ~13 hand-written fallbacks that existed in four spellings
|
|
— half used ``.get("name", title)``, which kept an EMPTY name instead of
|
|
falling through to the title."""
|
|
return str((entry.data.get(CONF_OBJECT) or {}).get("name") or entry.title)
|
|
|
|
|
|
def object_slug(obj_data: Mapping[str, Any]) -> str:
|
|
"""The object slug every per-task entity's unique_id is built from.
|
|
|
|
THE registration rule of the sensor / binary_sensor / button platforms
|
|
(``name`` as stored, ``"unknown"`` only when the key is missing). The
|
|
registry LOOKUPS of the task sensor (coordinator, list_tasks, the
|
|
notification context, the logbook) each derived it on their own and two
|
|
had drifted — an object with an empty name resolved to no sensor there
|
|
(DRY audit 2026-09-26 B). A non-string name (never registered) reads
|
|
like a missing one instead of raising.
|
|
"""
|
|
name = obj_data.get("name", "unknown")
|
|
return slugify_object_name(name if isinstance(name, str) else "unknown")
|
|
|
|
|
|
def task_entity_id(hass: HomeAssistant, slug: str, task_id: str, *, platform: str = "sensor", suffix: str = "") -> str | None:
|
|
"""The REGISTERED entity_id of a per-task entity (users rename them), or
|
|
None when the registry does not know it. ``slug`` = :func:`object_slug`."""
|
|
return er.async_get(hass).async_get_entity_id(platform, DOMAIN, task_unique_id(slug, task_id, suffix))
|
|
|
|
|
|
def task_sensor_entity_id(hass: HomeAssistant, obj_data: Mapping[str, Any], task_id: str) -> str | None:
|
|
"""The registered entity_id of a task's status sensor, from the object's
|
|
stored data — the one lookup behind every "which sensor is this task?"."""
|
|
return task_entity_id(hass, object_slug(obj_data), task_id)
|
|
|
|
|
|
def merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
|
|
"""Merged task data (static ConfigEntry + dynamic Store) for an entry.
|
|
|
|
THE read path for task dicts outside the coordinator — it was re-implemented
|
|
eight times, and only one copy overlaid the in-cycle checklist ticks. The
|
|
ticks are overlaid HERE rather than via the Store merge whitelist: merged
|
|
dicts feed MaintenanceTask.from_dict all over the coordinator, and this
|
|
field is presentation state the model never needs. Degrades to the static
|
|
data when the Store is unavailable.
|
|
"""
|
|
tasks_data: dict[str, Any] = entry.data.get(CONF_TASKS, {})
|
|
rd = getattr(entry, "runtime_data", None)
|
|
store = getattr(rd, "store", None) if rd else None
|
|
if store is None:
|
|
return tasks_data
|
|
merged: dict[str, Any] = store.merge_all_tasks(tasks_data)
|
|
for tid, td in merged.items():
|
|
progress = store.get_task_state(tid).get("checklist_progress")
|
|
if progress:
|
|
td["checklist_progress"] = progress
|
|
return merged
|
|
|
|
|
|
def get_object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]:
|
|
"""Return all non-global config entries for this domain, optionally
|
|
narrowed to a selection (``entry_ids=None`` means all objects)."""
|
|
return [
|
|
entry
|
|
for entry in hass.config_entries.async_entries(DOMAIN)
|
|
if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids)
|
|
]
|
|
|
|
|
|
def get_runtime_data(hass: HomeAssistant, entry_id: str) -> MaintenanceSupporterData | None:
|
|
"""Get runtime data for a config entry."""
|
|
entry = hass.config_entries.async_get_entry(entry_id)
|
|
if entry is None:
|
|
return None
|
|
return getattr(entry, "runtime_data", None)
|
|
|
|
|
|
def get_store(hass: HomeAssistant, entry_id: str) -> MaintenanceStore | None:
|
|
"""The entry's dynamic-state Store, or None when the entry is unknown or
|
|
not loaded (disabled / setup-retry / mid-reload = no runtime_data).
|
|
|
|
``store = getattr(rd, "store", None) if rd else None`` was copied at nine
|
|
WS sites — this is the one spelling."""
|
|
rd = get_runtime_data(hass, entry_id)
|
|
return getattr(rd, "store", None) if rd else None
|
|
|
|
|
|
def get_coordinator_data(hass: HomeAssistant, entry_id: str) -> dict[str, Any] | None:
|
|
"""The entry coordinator's live data, or None when it is not loaded."""
|
|
rd = get_runtime_data(hass, entry_id)
|
|
coordinator = getattr(rd, "coordinator", None) if rd else None
|
|
return coordinator.data if coordinator is not None else None
|
|
|
|
|
|
def iter_live_tasks(hass: HomeAssistant, entry_id: str | None = None) -> Iterator[tuple[ConfigEntry, str, dict[str, Any]]]:
|
|
"""``(entry, task_id, computed task)`` for every non-archived task of
|
|
every LOADED object (optionally one object only).
|
|
|
|
The coordinator's computed payload, so ``_status`` / ``_next_due`` reflect
|
|
the Store rather than stale entry data. An object that is not loaded
|
|
(disabled, setup retry, mid-reload) has no coordinator data and
|
|
contributes nothing. THE cross-object walk behind the ``list_tasks``
|
|
service and the Assist task snapshot, which each carried a copy of it
|
|
(DRY audit 2026-09-26 B).
|
|
"""
|
|
for entry in get_object_entries(hass):
|
|
if entry_id is not None and entry.entry_id != entry_id:
|
|
continue
|
|
rd = getattr(entry, "runtime_data", None)
|
|
coordinator = getattr(rd, "coordinator", None) if rd else None
|
|
if coordinator is None or not coordinator.data:
|
|
continue
|
|
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
|
|
if str(task.get("_status", "")) == MaintenanceStatus.ARCHIVED:
|
|
continue
|
|
yield entry, task_id, task
|
|
|
|
|
|
def compute_status_counts(hass: HomeAssistant) -> dict[str, Any]:
|
|
"""Aggregate task status counts across every maintenance object.
|
|
|
|
Status counts come from the live coordinator data (``_status``), which
|
|
already forces disabled tasks to OK. ``total_tasks`` is the configured
|
|
task count (static), matching the historical statistics-endpoint shape.
|
|
"""
|
|
counts = {str(s): 0 for s in _COUNTED_STATUSES}
|
|
total_objects = 0
|
|
total_tasks = 0
|
|
total_cost = 0.0
|
|
|
|
for entry in get_object_entries(hass):
|
|
total_objects += 1
|
|
# Archived tasks are inert: excluded from the task total and (via their
|
|
# ARCHIVED _status, which isn't a counted bucket) from every status
|
|
# count. Their cost still counts — budget is retained on archive.
|
|
total_tasks += sum(1 for td in entry.data.get(CONF_TASKS, {}).values() if td.get("archived_at") is None)
|
|
|
|
coord_data = get_coordinator_data(hass, entry.entry_id)
|
|
for task in (coord_data or {}).get(CONF_TASKS, {}).values():
|
|
status = str(task.get("_status", MaintenanceStatus.OK))
|
|
if status in counts:
|
|
counts[status] += 1
|
|
total_cost += task.get("_total_cost", 0.0) or 0.0
|
|
|
|
needs_attention = (
|
|
counts[str(MaintenanceStatus.OVERDUE)]
|
|
+ counts[str(MaintenanceStatus.DUE_SOON)]
|
|
+ counts[str(MaintenanceStatus.TRIGGERED)]
|
|
)
|
|
|
|
return {
|
|
"total_objects": total_objects,
|
|
"total_tasks": total_tasks,
|
|
"overdue": counts[str(MaintenanceStatus.OVERDUE)],
|
|
"due_soon": counts[str(MaintenanceStatus.DUE_SOON)],
|
|
"triggered": counts[str(MaintenanceStatus.TRIGGERED)],
|
|
"ok": counts[str(MaintenanceStatus.OK)],
|
|
"needs_attention": needs_attention,
|
|
"total_cost": round(total_cost, 2),
|
|
}
|