217 files
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user