"""Assist intents — query + complete maintenance tasks by voice / LLM agents. Registered through HA's integration intent platform (``async_setup_intents``, picked up automatically when the ``intent`` component loads — same mechanism as shopping_list): * ``MaintenanceSupporterListTasks`` — *"what maintenance is due?"* Speaks a snapshot of actionable tasks, optionally filtered by status. * ``MaintenanceSupporterCompleteTask`` — *"complete the oil change"* — fuzzy- 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 sentence-matching agent additionally needs the copy-paste sentence files shipped under ``assist/custom_sentences/`` (see FEATURES → Voice & Assist). """ from __future__ import annotations import re 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 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" INTENT_POSTPONE_TASK = "MaintenanceSupporterPostponeTask" INTENT_SKIP_TASK = "MaintenanceSupporterSkipTask" _ACTIONABLE = ("due_soon", "overdue", "triggered") # Spoken responses live in assist_sentences/responses/.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: """Spoken text for *key*, in the requesting language. 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]]: """Live snapshot of every active (non-archived) task across all objects. Same shape/source as the ``list_tasks`` service: the coordinator's computed payload, so status/next_due reflect the Store, not stale entry data. """ tasks: list[dict[str, Any]] = [] for ce in hass.config_entries.async_entries(DOMAIN): if ce.unique_id == GLOBAL_UNIQUE_ID: continue rd = getattr(ce, "runtime_data", None) coordinator = getattr(rd, "coordinator", None) if rd else None if coordinator is None or not coordinator.data: continue 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": continue tasks.append( { "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"), } ) 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). An exact task-name match wins outright; otherwise every ≥2-char query token must appear in "task name + object name" (case-insensitive substring). """ q = query.strip().lower() if not q: return [] exact = [t for t in snapshot if t["name"].lower() == q] if len(exact) == 1: return exact tokens = [tok for tok in re.split(r"[^a-z0-9äöüß]+", q) if len(tok) >= 2] if not tokens: return exact matches = [] for t in snapshot: hay = f"{t['name']} {t['object_name']}".lower() if all(tok in hay for tok in tokens): matches.append(t) return exact or matches def _describe(task: dict[str, Any], language: str | None) -> str: days = task.get("days_until_due") if task["status"] == "triggered": desc = _sp("st_triggered", language) elif isinstance(days, int) and days < 0: # "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): 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})" 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: # 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] ) 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.""" # 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): """Speak which maintenance tasks need attention (optionally by status).""" intent_type = INTENT_LIST_TASKS description = ( "Lists the user's home-maintenance tasks that need attention " "(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"]), 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] 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: 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" response.async_set_speech(_sp(key, lang, count=len(tasks), items=items)) return response class CompleteTaskIntent(intent.IntentHandler): """Complete a maintenance task by its spoken name.""" intent_type = INTENT_COMPLETE_TASK description = ( "Marks a home-maintenance task as completed, matched by its name " "(the object/appliance name may be included, e.g. 'oil change on the car'). " "Records a real completion including history." ) 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) coordinator = getattr(rd, "coordinator", None) if rd else None if coordinator is None: response.async_set_error( intent.IntentResponseErrorCode.FAILED_TO_HANDLE, _sp("not_found", lang, name=name), ) return response # Honour the completion window (earliest_completion_days) like the WS # and To-do paths do — voice must not bypass the contract. from .websocket.tasks_actions import _completion_blocked if _completion_blocked(rd, target["task_id"]): response.async_set_error( intent.IntentResponseErrorCode.FAILED_TO_HANDLE, _sp("too_early", lang, task=target["name"]), ) return response 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"]) ) 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 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