429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

@@ -88,6 +88,11 @@ def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
status = str(task.get("_status", ""))
if status == "archived":
continue
# Cycle phases (#139): name the step currently due, so answers
# say WHICH work is meant ("Mower blades — next step: replace").
from .helpers.phases import current_phase
phase = current_phase(task)
tasks.append(
{
"entry_id": ce.entry_id,
@@ -102,6 +107,10 @@ def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
"status": status,
"days_until_due": task.get("_days_until_due"),
"next_due": task.get("_next_due"),
"phase": str(phase["name"]) if phase else None,
# #134: high-priority work is named as such and listed first
# within the same urgency bucket.
"priority": str(task.get("priority") or "normal"),
}
)
return tasks
@@ -185,7 +194,12 @@ def _describe(task: dict[str, Any], language: str | None) -> str:
desc = _sp(key, language, days=days)
else:
desc = task["status"]
return f"{_sp('item_on', language, task=task['name'], object=task['object_name'])} ({desc})"
if task.get("priority") == "high":
desc = f"{desc}{_sp('st_priority', language)}"
line = f"{_sp('item_on', language, task=task['name'], object=task['object_name'])} ({desc})"
if task.get("phase"):
line = f"{line}{_sp('st_phase', language, phase=task['phase'])}"
return line
def _resolve_single(
@@ -295,7 +309,15 @@ class ListTasksIntent(intent.IntentHandler):
tasks = [t for t in tasks if t.get("area_id") == area]
# Most urgent first: overdue (most days) → due today → due soon.
tasks.sort(key=lambda t: (t["days_until_due"] is None, t["days_until_due"] or 0))
# Within the same due day, high-priority work is named first (#134).
_PRIO_RANK = {"high": 0, "normal": 1, "low": 2}
tasks.sort(
key=lambda t: (
t["days_until_due"] is None,
t["days_until_due"] or 0,
_PRIO_RANK.get(t.get("priority") or "normal", 1),
)
)
if not tasks:
empty = {"mine": "none_due_mine", "here": "none_due_here"}.get(scope, "none_due")
@@ -351,9 +373,17 @@ class CompleteTaskIntent(intent.IntentHandler):
)
return response
# Cycle phases (#139): remember the step this completion performs, so
# the confirmation can say what was DONE and what comes next.
done_phase = target.get("phase")
try:
await coordinator.complete_maintenance(
task_id=target["task_id"], completed_by="assist", unattended=True
task_id=target["task_id"],
# The speaking user when the pipeline knows one; "assist" was a
# sentinel that never matched a pool member (bug audit 2026-08-29).
completed_by=intent_obj.context.user_id if intent_obj.context else None,
unattended=True,
)
except ServiceValidationError as err:
# The task demands details voice cannot capture (a photo, a cost).
@@ -363,6 +393,23 @@ class CompleteTaskIntent(intent.IntentHandler):
str(err),
)
return response
if done_phase:
from .helpers.phases import current_phase
next_task = (coordinator.data or {}).get(CONF_TASKS, {}).get(target["task_id"]) or {}
next_phase = current_phase(next_task)
response.async_set_speech(
_sp(
"completed_phase",
lang,
task=target["name"],
object=target["object_name"],
phase=done_phase,
next=str(next_phase["name"]) if next_phase else done_phase,
)
)
return response
response.async_set_speech(
_sp("completed", lang, task=target["name"], object=target["object_name"])
)
@@ -405,10 +452,32 @@ class TaskInstructionsIntent(intent.IntentHandler):
# computed subset (consumes_parts, for one, is not in it).
task: dict[str, Any] = {}
if entry is not None:
task = entry.data.get(CONF_TASKS, {}).get(target["task_id"], {}) or {}
task = dict(entry.data.get(CONF_TASKS, {}).get(target["task_id"], {}) or {})
# Cycle phases (#139): the guidance must describe the step currently
# DUE. Static defs live in entry.data; the cursor is Store state —
# splice it in so current_phase/effective_field resolve like every
# other surface. Falls through untouched for phase-less tasks.
from .helpers.phases import current_phase, effective_field
store = getattr(rd, "store", None) if rd else None
if store is not None and task.get("phases"):
task["phase_cursor"] = store.get_task_state(target["task_id"]).get("phase_cursor", 0)
phase = current_phase(task)
segments: list[str] = []
if phase:
segments.append(
_sp(
"guide_phase",
lang,
phase=str(phase["name"]),
index=int(phase["index"]) + 1,
count=int(phase["count"]),
)
)
notes = task.get("notes")
if isinstance(notes, str) and notes.strip():
trimmed = notes.strip()
@@ -416,7 +485,11 @@ class TaskInstructionsIntent(intent.IntentHandler):
trimmed = trimmed[:237] + "…"
segments.append(_sp("guide_notes", lang, notes=trimmed))
checklist = [s for s in (task.get("checklist") or []) if isinstance(s, str) and s.strip()]
checklist = [
s
for s in (effective_field(task, "checklist") or [])
if isinstance(s, str) and s.strip()
]
if checklist:
segments.append(
_sp("guide_checklist", lang, count=len(checklist), steps="; ".join(checklist[:8]))
@@ -444,8 +517,7 @@ class TaskInstructionsIntent(intent.IntentHandler):
# Required spare parts with storage location + live stock.
parts = (entry.data.get(CONF_PARTS) or {}) if entry is not None else {}
store = getattr(rd, "store", None) if rd else None
for link in task.get("consumes_parts") or []:
for link in effective_field(task, "consumes_parts") or []:
part = parts.get(link.get("part_id")) if isinstance(link, dict) else None
if not isinstance(part, dict):
continue