217 files

This commit is contained in:
Home Assistant Version Control
2026-07-30 23:59:38 +00:00
parent d43a63ad29
commit 7b5e46e702
217 changed files with 15978 additions and 3912 deletions
+294 -124
View File
@@ -33,6 +33,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import intent
@@ -44,127 +45,26 @@ INTENT_TASK_INSTRUCTIONS = "MaintenanceSupporterTaskInstructions"
INTENT_TASK_DUE = "MaintenanceSupporterTaskDue"
INTENT_SNOOZE_TASK = "MaintenanceSupporterSnoozeTask"
INTENT_PART_STOCK = "MaintenanceSupporterPartStock"
INTENT_POSTPONE_TASK = "MaintenanceSupporterPostponeTask"
INTENT_SKIP_TASK = "MaintenanceSupporterSkipTask"
_ACTIONABLE = ("due_soon", "overdue", "triggered")
# Spoken responses. The LLM path re-phrases in the user's language anyway; the
# classic sentence agent speaks these verbatim, so the languages we ship
# sentence files for (en, de) are fully localised here — others fall back to en.
_SPEECH: dict[str, dict[str, str]] = {
"none_due": {
"en": "Everything is OK — no maintenance needs attention.",
"de": "Alles in Ordnung — keine Wartung fällig.",
},
"tasks_due": {
"en": "{count} maintenance tasks need attention: {items}.",
"de": "{count} Wartungsaufgaben brauchen Aufmerksamkeit: {items}.",
},
"task_due_one": {
"en": "One maintenance task needs attention: {items}.",
"de": "Eine Wartungsaufgabe braucht Aufmerksamkeit: {items}.",
},
"completed": {
"en": "Completed '{task}' on {object}.",
"de": "'{task}' an {object} als erledigt eingetragen.",
},
"not_found": {
"en": "I couldn't find a maintenance task matching '{name}'.",
"de": "Ich habe keine Wartungsaufgabe zu '{name}' gefunden.",
},
"ambiguous": {
"en": "That matches several tasks: {candidates}. Please be more specific.",
"de": "Das passt auf mehrere Aufgaben: {candidates}. Bitte formuliere genauer.",
},
"too_early": {
"en": "'{task}' can only be completed closer to its due date.",
"de": "'{task}' kann erst näher am Fälligkeitstermin erledigt werden.",
},
# status descriptors for list items
"st_overdue": {"en": "{days} days overdue", "de": "seit {days} Tagen überfällig"},
"st_due_today": {"en": "due today", "de": "heute fällig"},
"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.",
},
}
# Spoken responses live in assist_sentences/responses/<lang>.json — 38 keys
# across 22 languages is far too much to read past on the way to the
# handlers, and a translator should not have to edit Python. The loader and
# the English-per-key fallback live in helpers/intent_speech.
def _sp(key: str, language: str | None, **fmt: Any) -> str:
# Per-request language (intent_obj.language, e.g. "de-DE") → table key,
# same normalization rule as helpers/i18n (which is hass-bound).
from .helpers.i18n import normalize_language_code
"""Spoken text for *key*, in the requesting language.
lang = normalize_language_code(language)
table = _SPEECH[key]
return table.get(lang, table["en"]).format(**fmt)
Thin seam on purpose: every handler already calls ``_sp``, so the move
to per-language files needed no changes at the call sites.
"""
from .helpers.intent_speech import speak
return speak(key, language, **fmt)
def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
@@ -181,7 +81,8 @@ def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
coordinator = getattr(rd, "coordinator", None) if rd else None
if coordinator is None or not coordinator.data:
continue
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title)
obj = ce.data.get(CONF_OBJECT, {})
object_name = obj.get("name", ce.title)
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
status = str(task.get("_status", ""))
if status == "archived":
@@ -191,7 +92,12 @@ def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
"entry_id": ce.entry_id,
"task_id": task_id,
"object_name": object_name,
# The object's room, so a satellite can answer for where it
# is standing rather than for the whole house.
"area_id": obj.get("area_id") or None,
"name": str(task.get("name") or ""),
# Whose turn it is — for a rotation this is the current duty.
"responsible_user_id": task.get("responsible_user_id") or None,
"status": status,
"days_until_due": task.get("_days_until_due"),
"next_due": task.get("_next_due"),
@@ -200,6 +106,43 @@ def _task_snapshot(hass: HomeAssistant) -> list[dict[str, Any]]:
return tasks
def _asking_area(intent_obj: intent.Intent) -> str | None:
"""Which area the request came from, or None if we cannot tell.
Home Assistant hands the handler both the device that captured the speech
and (for voice satellites) the satellite entity. Either can carry the area:
an entity's own area override wins, otherwise its device's.
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
hass = intent_obj.hass
satellite_id = getattr(intent_obj, "satellite_id", None)
if satellite_id:
entry = er.async_get(hass).async_get(satellite_id)
if entry is not None:
if entry.area_id:
return entry.area_id
if entry.device_id:
device = dr.async_get(hass).async_get(entry.device_id)
if device is not None and device.area_id:
return device.area_id
if intent_obj.device_id:
device = dr.async_get(hass).async_get(intent_obj.device_id)
if device is not None and device.area_id:
return device.area_id
return None
def _asking_user(intent_obj: intent.Intent) -> str | None:
"""The Home Assistant user who spoke, when the pipeline knows one."""
context = getattr(intent_obj, "context", None)
return getattr(context, "user_id", None) if context else None
def _match_tasks(query: str, snapshot: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Fuzzy-match a spoken name against tasks (object name counts too).
@@ -228,11 +171,17 @@ def _describe(task: dict[str, Any], language: str | None) -> str:
if task["status"] == "triggered":
desc = _sp("st_triggered", language)
elif isinstance(days, int) and days < 0:
desc = _sp("st_overdue", language, days=-days)
# "1 days overdue" was shipped in English and German alike; a single
# string cannot inflect, so the singular is its own key. Several
# languages need it far more than English does — Czech, Russian and
# Ukrainian govern the noun's case by the numeral.
key = "st_overdue_one" if days == -1 else "st_overdue"
desc = _sp(key, language, days=-days)
elif days == 0:
desc = _sp("st_due_today", language)
elif isinstance(days, int):
desc = _sp("st_due_in", language, days=days)
key = "st_due_in_one" if days == 1 else "st_due_in"
desc = _sp(key, language, days=days)
else:
desc = task["status"]
return f"{_sp('item_on', language, task=task['name'], object=task['object_name'])} ({desc})"
@@ -256,6 +205,15 @@ def _resolve_single(
)
return None, response
if len(matches) > 1:
# Before giving up, let the room decide. "Complete the filter change"
# spoken at the utility-room satellite means the one in the utility
# room — and getting this right matters more than convenience, because
# a voice completion writes real history through the coordinator.
area = _asking_area(intent_obj)
if area:
local = [t for t in matches if t.get("area_id") == area]
if len(local) == 1:
return local[0], None
candidates = ", ".join(
_sp("item_on", lang, task=t["name"], object=t["object_name"]) for t in matches[:4]
)
@@ -269,12 +227,20 @@ def _resolve_single(
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Register the Maintenance Supporter intents."""
# Read the response texts now, in the executor: a handler answering a
# spoken question must not block the event loop on disk.
from .helpers.intent_speech import async_load
await async_load(hass)
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())
intent.async_register(hass, PostponeTaskIntent())
intent.async_register(hass, SkipTaskIntent())
class ListTasksIntent(intent.IntentHandler):
@@ -283,24 +249,56 @@ class ListTasksIntent(intent.IntentHandler):
intent_type = INTENT_LIST_TASKS
description = (
"Lists the user's home-maintenance tasks that need attention "
"(overdue, due soon or sensor-triggered), or filtered by a status. "
"Use for questions like 'what maintenance is due?'"
"(overdue, due soon or sensor-triggered), optionally filtered by a "
"status. Use for questions like 'what maintenance is due?'. Set scope "
"to 'mine' for the tasks assigned to the person asking (including "
"whose turn it is on a rotating chore), or 'here' for the tasks "
"belonging to the room the request came from."
)
slot_schema = {vol.Optional("status"): vol.In(["ok", "due_soon", "overdue", "triggered"])}
slot_schema = {
vol.Optional("status"): vol.In(["ok", "due_soon", "overdue", "triggered"]),
vol.Optional("scope"): vol.In(["all", "mine", "here"]),
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
slots = self.async_validate_slots(intent_obj.slots)
wanted = slots.get("status", {}).get("value")
scope = slots.get("scope", {}).get("value") or "all"
statuses = (wanted,) if wanted else _ACTIONABLE
tasks = [t for t in _task_snapshot(intent_obj.hass) if t["status"] in statuses]
# 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))
response = intent_obj.create_response()
lang = intent_obj.language
# A scope we cannot resolve is answered honestly rather than silently
# widened: "everything in the house" is a plausible-sounding wrong
# answer to "what needs doing in here?".
if scope == "mine":
user_id = _asking_user(intent_obj)
if not user_id:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("unknown_user", lang),
)
return response
tasks = [t for t in tasks if t.get("responsible_user_id") == user_id]
elif scope == "here":
area = _asking_area(intent_obj)
if not area:
response.async_set_error(
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
_sp("unknown_area", lang),
)
return response
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))
if not tasks:
response.async_set_speech(_sp("none_due", lang))
empty = {"mine": "none_due_mine", "here": "none_due_here"}.get(scope, "none_due")
response.async_set_speech(_sp(empty, lang))
return response
items = ", ".join(_describe(t, lang) for t in tasks[:8])
key = "task_due_one" if len(tasks) == 1 else "tasks_due"
@@ -352,7 +350,18 @@ class CompleteTaskIntent(intent.IntentHandler):
)
return response
await coordinator.complete_maintenance(task_id=target["task_id"], completed_by="assist")
try:
await coordinator.complete_maintenance(
task_id=target["task_id"], completed_by="assist", unattended=True
)
except ServiceValidationError as err:
# The task demands details voice cannot capture (a photo, a cost).
# Say so plainly instead of failing with a generic error.
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
str(err),
)
return response
response.async_set_speech(
_sp("completed", lang, task=target["name"], object=target["object_name"])
)
@@ -630,3 +639,164 @@ class PartStockIntent(intent.IntentHandler):
_sp("stock_line", lang, stock=part["stock"], part=part["name"], loc=loc, low=low)
)
return response
def _resolve_coordinator(
intent_obj: intent.Intent, target: dict[str, Any], name: str
) -> tuple[Any, intent.IntentResponse | None]:
"""The coordinator behind a matched task, or a spoken failure."""
entry = intent_obj.hass.config_entries.async_get_entry(target["entry_id"])
rd = getattr(entry, "runtime_data", None)
coordinator = getattr(rd, "coordinator", None) if rd else None
if coordinator is None:
response = intent_obj.create_response()
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("not_found", intent_obj.language, name=name),
)
return None, response
return coordinator, None
class PostponeTaskIntent(intent.IntentHandler):
"""Defer just this occurrence of a task, spoken."""
intent_type = INTENT_POSTPONE_TASK
description = (
"Postpones the CURRENT occurrence of a home-maintenance task to a later "
"date without completing it; the recurring cadence is untouched. Give "
"either days (how many days to push it back) or date (YYYY-MM-DD). Use "
"for 'postpone the oil change by a week'. This is not the same as "
"snoozing, which only mutes reminders."
)
slot_schema = {
vol.Required("name"): cv.string,
vol.Optional("days"): vol.Coerce(int),
vol.Optional("date"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
from datetime import date as date_cls
from datetime import timedelta
from homeassistant.util import dt as dt_util
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name = str(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()
today = dt_util.now().date()
spoken_date = slots.get("date", {}).get("value")
days = slots.get("days", {}).get("value")
if spoken_date:
try:
until = date_cls.fromisoformat(str(spoken_date))
except ValueError:
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("postpone_needs_when", lang),
)
return response
elif days is not None:
# Counted from the due date, or from today when that has already
# passed: "postpone by three days" on a task that went overdue last
# month must not land on a date that is still in the past.
base = today
next_due = target.get("next_due")
if next_due:
try:
base = max(date_cls.fromisoformat(str(next_due)[:10]), today)
except ValueError:
base = today
until = base + timedelta(days=int(days))
else:
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("postpone_needs_when", lang),
)
return response
if until <= today:
response.async_set_error(
intent.IntentResponseErrorCode.FAILED_TO_HANDLE,
_sp("postpone_past", lang, task=target["name"]),
)
return response
coordinator, err = _resolve_coordinator(intent_obj, target, name)
if err is not None:
return err
await coordinator.async_postpone_task(target["task_id"], until)
response.async_set_speech(
_sp(
"postponed",
lang,
task=target["name"],
object=target["object_name"],
date=until.isoformat(),
)
)
return response
class SkipTaskIntent(intent.IntentHandler):
"""Skip the current cycle of a task, spoken."""
intent_type = INTENT_SKIP_TASK
description = (
"Skips the CURRENT cycle of a home-maintenance task: the task is not "
"recorded as done, and the schedule moves on to the next occurrence. "
"Use for 'skip the lawn mowing this time'."
)
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 = str(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
coordinator, err = _resolve_coordinator(intent_obj, target, name)
if err is not None:
return err
await coordinator.skip_maintenance(target["task_id"])
# Read the new due date back so the answer says what actually happened
# rather than just acknowledging the command.
fresh = next(
(
t
for t in _task_snapshot(hass)
if t["entry_id"] == target["entry_id"] and t["task_id"] == target["task_id"]
),
None,
)
due = str((fresh or {}).get("next_due") or "")[:10]
response = intent_obj.create_response()
response.async_set_speech(
_sp(
"skipped",
lang,
task=target["name"],
object=target["object_name"],
date=due or "?",
)
)
return response