Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+393 -19
View File
@@ -10,6 +10,15 @@ shopping_list):
matches a task by its spoken name (the object name counts too, so "oil change
on the car" works) and records a REAL completion through the coordinator —
history, rotation, part consumption and on-complete actions all fire.
* ``MaintenanceSupporterTaskInstructions`` — *"how do I descale the coffee
machine?"* — answers STRICTLY from what is stored on the task (notes,
checklist, linked documents incl. per-task page hints, required spare parts,
documentation link). When nothing is stored it says so and asks whether the
user wants general, non-verified advice — grounded by design, never invented.
* ``MaintenanceSupporterTaskDue`` — *"when is the oil change due?"*
* ``MaintenanceSupporterSnoozeTask`` — *"snooze the oil change"* — suppresses
the task's reminders for the configured snooze duration.
* ``MaintenanceSupporterPartStock`` — *"how many water filters do we have?"*
LLM-based Assist pipelines expose every registered intent handler as a tool
automatically (``helpers/llm``), in any language — no setup needed. The classic
@@ -31,6 +40,10 @@ from .const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID
INTENT_LIST_TASKS = "MaintenanceSupporterListTasks"
INTENT_COMPLETE_TASK = "MaintenanceSupporterCompleteTask"
INTENT_TASK_INSTRUCTIONS = "MaintenanceSupporterTaskInstructions"
INTENT_TASK_DUE = "MaintenanceSupporterTaskDue"
INTENT_SNOOZE_TASK = "MaintenanceSupporterSnoozeTask"
INTENT_PART_STOCK = "MaintenanceSupporterPartStock"
_ACTIONABLE = ("due_soon", "overdue", "triggered")
@@ -72,6 +85,75 @@ _SPEECH: dict[str, dict[str, str]] = {
"st_due_in": {"en": "due in {days} days", "de": "fällig in {days} Tagen"},
"st_triggered": {"en": "triggered", "de": "ausgelöst"},
"item_on": {"en": "{task} on {object}", "de": "{task} an {object}"},
# task due (single-task query)
"due_date_suffix": {
"en": " The next due date is {date}.",
"de": " Der nächste Fälligkeitstermin ist der {date}.",
},
# grounded task guidance
"guide_header": {
"en": "Stored guidance for '{task}' on {object}: {segments}.",
"de": "Hinterlegte Informationen zu '{task}' an {object}: {segments}.",
},
"guide_none": {
"en": (
"There are no stored instructions, documents or spare parts for "
"'{task}' on {object}. I can offer general, non-verified advice "
"instead — would you like that?"
),
"de": (
"Zu '{task}' an {object} sind keine Anleitungen, Dokumente oder "
"Ersatzteile hinterlegt. Ich kann stattdessen allgemeine, "
"ungeprüfte Hinweise geben — möchtest du das?"
),
},
"guide_notes": {"en": "notes: {notes}", "de": "Notizen: {notes}"},
"guide_checklist": {
"en": "{count} checklist steps: {steps}",
"de": "{count} Checklisten-Schritte: {steps}",
},
"guide_doc": {"en": "linked document '{title}'", "de": "verknüpftes Dokument '{title}'"},
"guide_doc_page": {
"en": "linked document '{title}', page {page}",
"de": "verknüpftes Dokument '{title}', Seite {page}",
},
"guide_url": {
"en": "a documentation link is on file",
"de": "ein Dokumentations-Link ist hinterlegt",
},
"guide_part": {
"en": "{qty} × {part} needed{extras}",
"de": "{qty} × {part} benötigt{extras}",
},
"guide_part_loc": {"en": "stored at {loc}", "de": "Lagerort {loc}"},
"guide_part_stock": {"en": "{stock} in stock", "de": "{stock} auf Lager"},
# snooze
"snoozed": {
"en": "Snoozed reminders for '{task}' on {object} for {hours} hours.",
"de": "Erinnerungen für '{task}' an {object} für {hours} Stunden stummgeschaltet.",
},
"snooze_unavailable": {
"en": "Notifications aren't configured, so there is nothing to snooze.",
"de": "Benachrichtigungen sind nicht eingerichtet — es gibt nichts stummzuschalten.",
},
# part stock
"stock_line": {
"en": "{stock} × {part} in stock{loc}{low}.",
"de": "{stock} × {part} auf Lager{loc}{low}.",
},
"stock_loc": {"en": " (stored at {loc})", "de": " (Lagerort: {loc})"},
"stock_low": {
"en": " — at or below the reorder threshold",
"de": " — an oder unter der Nachbestellgrenze",
},
"stock_untracked": {
"en": "Stock isn't tracked for {part}.",
"de": "Für {part} wird kein Bestand geführt.",
},
"part_not_found": {
"en": "I couldn't find a spare part matching '{name}'.",
"de": "Ich habe kein Ersatzteil zu '{name}' gefunden.",
},
}
@@ -154,10 +236,43 @@ def _describe(task: dict[str, Any], language: str | None) -> str:
return f"{_sp('item_on', language, task=task['name'], object=task['object_name'])} ({desc})"
def _resolve_single(
intent_obj: intent.Intent, name: str, snapshot: list[dict[str, Any]]
) -> tuple[dict[str, Any] | None, intent.IntentResponse | None]:
"""Match a spoken name to exactly ONE snapshot row.
Returns ``(row, None)`` on success, ``(None, error_response)`` otherwise —
the shared not-found / ambiguity contract of every name-taking intent.
"""
lang = intent_obj.language
response = intent_obj.create_response()
matches = _match_tasks(name, snapshot)
if not matches:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("not_found", lang, name=name),
)
return None, response
if len(matches) > 1:
candidates = ", ".join(
_sp("item_on", lang, task=t["name"], object=t["object_name"]) for t in matches[:4]
)
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("ambiguous", lang, candidates=candidates),
)
return None, response
return matches[0], None
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Register the Maintenance Supporter intents."""
intent.async_register(hass, ListTasksIntent())
intent.async_register(hass, CompleteTaskIntent())
intent.async_register(hass, TaskInstructionsIntent())
intent.async_register(hass, TaskDueIntent())
intent.async_register(hass, SnoozeTaskIntent())
intent.async_register(hass, PartStockIntent())
class ListTasksIntent(intent.IntentHandler):
@@ -208,26 +323,12 @@ class CompleteTaskIntent(intent.IntentHandler):
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
target, err = _resolve_single(intent_obj, name, _task_snapshot(hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
matches = _match_tasks(name, _task_snapshot(hass))
if not matches:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("not_found", lang, name=name),
)
return response
if len(matches) > 1:
candidates = ", ".join(
_sp("item_on", lang, task=t["name"], object=t["object_name"]) for t in matches[:4]
)
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("ambiguous", lang, candidates=candidates),
)
return response
target = matches[0]
entry = hass.config_entries.async_get_entry(target["entry_id"])
rd = getattr(entry, "runtime_data", None)
coordinator = getattr(rd, "coordinator", None) if rd else None
@@ -254,3 +355,276 @@ class CompleteTaskIntent(intent.IntentHandler):
_sp("completed", lang, task=target["name"], object=target["object_name"])
)
return response
class TaskInstructionsIntent(intent.IntentHandler):
"""Speak the STORED guidance for a task — grounded by design (roadmap)."""
intent_type = INTENT_TASK_INSTRUCTIONS
description = (
"Returns the guidance STORED on a home-maintenance task: notes, checklist "
"steps, linked manuals/documents (with a page hint), required spare parts "
"(with storage location and stock) and whether a documentation link is on "
"file. Use for 'how do I …' questions about maintenance tasks. IMPORTANT: "
"this returns only verified, user-stored information — if it reports that "
"nothing is stored, tell the user so and ask whether they want general "
"advice before providing any; never present invented steps as the stored "
"procedure."
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
target, err = _resolve_single(intent_obj, name, _task_snapshot(hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
entry = hass.config_entries.async_get_entry(target["entry_id"])
rd = getattr(entry, "runtime_data", None) if entry else None
# Static fields (notes/checklist/url/consumes_parts) come from the entry
# record — the system of record; the coordinator payload carries only a
# 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 {}
segments: list[str] = []
notes = task.get("notes")
if isinstance(notes, str) and notes.strip():
trimmed = notes.strip()
if len(trimmed) > 240:
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()]
if checklist:
segments.append(
_sp("guide_checklist", lang, count=len(checklist), steps="; ".join(checklist[:8]))
)
# Documents linked to THIS task, with the per-task page hint when set.
from . import DOCUMENT_STORE_KEY
from .const import CONF_PARTS
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None and entry is not None:
object_id = entry.data.get(CONF_OBJECT, {}).get("id", "")
for doc in doc_store.for_object(object_id):
if target["task_id"] not in (doc.get("task_ids") or []):
continue
title = doc.get("title") or doc.get("filename") or doc.get("url") or "document"
page = (doc.get("task_pages") or {}).get(target["task_id"])
if page:
segments.append(_sp("guide_doc_page", lang, title=title, page=page))
else:
segments.append(_sp("guide_doc", lang, title=title))
if isinstance(task.get("documentation_url"), str) and task["documentation_url"].strip():
segments.append(_sp("guide_url", lang))
# 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 []:
part = parts.get(link.get("part_id")) if isinstance(link, dict) else None
if not isinstance(part, dict):
continue
extras: list[str] = []
if part.get("storage_location"):
extras.append(_sp("guide_part_loc", lang, loc=part["storage_location"]))
stock = store.get_part_stock(link["part_id"]) if store is not None else None
if stock is not None:
extras.append(_sp("guide_part_stock", lang, stock=stock))
segments.append(
_sp(
"guide_part",
lang,
qty=link.get("quantity", 1),
part=part.get("name") or "part",
extras=f" ({', '.join(extras)})" if extras else "",
)
)
if not segments:
# Grounded contract: nothing stored → say so and ASK before any
# general advice — the LLM relays the question instead of inventing.
response.async_set_speech(
_sp("guide_none", lang, task=target["name"], object=target["object_name"])
)
return response
response.async_set_speech(
_sp(
"guide_header",
lang,
task=target["name"],
object=target["object_name"],
segments="; ".join(segments),
)
)
return response
class TaskDueIntent(intent.IntentHandler):
"""Answer when a single task is due."""
intent_type = INTENT_TASK_DUE
description = (
"Tells when a single home-maintenance task is due, matched by its name "
"(the object/appliance name may be included). Use for questions like "
"'when is the oil change due?'"
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
target, err = _resolve_single(intent_obj, name, _task_snapshot(intent_obj.hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
speech = _describe(target, lang) + "."
next_due = target.get("next_due")
if isinstance(next_due, str) and next_due:
speech += _sp("due_date_suffix", lang, date=next_due.split("T")[0])
response.async_set_speech(speech)
return response
class SnoozeTaskIntent(intent.IntentHandler):
"""Snooze a task's reminders for the configured snooze duration."""
intent_type = INTENT_SNOOZE_TASK
description = (
"Snoozes (mutes) the reminder notifications of a home-maintenance task "
"for the configured snooze duration. Does NOT change the task's schedule "
"or complete it."
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
target, err = _resolve_single(intent_obj, name, _task_snapshot(hass))
if err is not None:
return err
assert target is not None
response = intent_obj.create_response()
from . import NOTIFICATION_MANAGER_KEY
from .const import CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS
from .helpers.global_options import get_global_options
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
if nm is None:
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("snooze_unavailable", lang),
)
return response
nm.snooze_task(target["entry_id"], target["task_id"])
hours = get_global_options(hass).get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS)
if isinstance(hours, float) and hours.is_integer():
hours = int(hours) # "4 hours", not "4.0 hours"
response.async_set_speech(
_sp("snoozed", lang, task=target["name"], object=target["object_name"], hours=hours)
)
return response
def _part_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Every spare part across all objects, in the _match_tasks row shape
(``name`` + ``object_name``) so the same fuzzy matcher applies."""
from .const import CONF_PARTS
rows: list[dict[str, Any]] = []
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
continue
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title)
rd = getattr(ce, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
for part_id, part in (ce.data.get(CONF_PARTS) or {}).items():
if not isinstance(part, dict):
continue
rows.append(
{
"name": str(part.get("name") or ""),
"object_name": object_name,
"storage_location": part.get("storage_location"),
"reorder_threshold": part.get("reorder_threshold"),
"stock": store.get_part_stock(part_id) if store is not None else None,
}
)
return rows
class PartStockIntent(intent.IntentHandler):
"""Answer how many of a spare part are in stock."""
intent_type = INTENT_PART_STOCK
description = (
"Tells how many of a spare part / consumable are in stock (with the "
"storage location), matched by the part's name. Use for questions like "
"'how many water filters do we have left?'"
)
slot_schema = {vol.Required("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
slots = self.async_validate_slots(intent_obj.slots)
name = slots["name"]["value"].strip()
lang = intent_obj.language
response = intent_obj.create_response()
matches = _match_tasks(name, _part_snapshot(intent_obj.hass))
if not matches:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("part_not_found", lang, name=name),
)
return response
if len(matches) > 1:
candidates = ", ".join(
_sp("item_on", lang, task=p["name"], object=p["object_name"]) for p in matches[:4]
)
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("ambiguous", lang, candidates=candidates),
)
return response
part = matches[0]
if part["stock"] is None:
response.async_set_speech(_sp("stock_untracked", lang, part=part["name"]))
return response
loc = _sp("stock_loc", lang, loc=part["storage_location"]) if part.get("storage_location") else ""
threshold = part.get("reorder_threshold")
low = (
_sp("stock_low", lang)
if isinstance(threshold, int) and part["stock"] <= threshold
else ""
)
response.async_set_speech(
_sp("stock_line", lang, stock=part["stock"], part=part["name"], loc=loc, low=low)
)
return response